fix: add email reporting (#2918)

This commit is contained in:
David Nguyen
2026-06-03 16:05:39 +10:00
committed by GitHub
parent 743d31651f
commit 993a494784
24 changed files with 521 additions and 33 deletions
@@ -1,13 +1,19 @@
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
import { Progress } from '@documenso/ui/primitives/progress';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Trans } from '@lingui/react/macro';
import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client';
import { useState } from 'react';
import { match } from 'ts-pattern';
import { OrganisationUsageResetButton } from './organisation-usage-reset-button';
type OrganisationUsagePanelProps = {
organisationId: string;
monthlyStats: Pick<OrganisationMonthlyStat, 'period' | 'documentCount' | 'emailCount' | 'apiCount'>[];
monthlyStats: Pick<
OrganisationMonthlyStat,
'period' | 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports'
>[];
organisationClaim: OrganisationClaim;
};
@@ -16,34 +22,56 @@ export const OrganisationUsagePanel = ({
monthlyStats,
organisationClaim,
}: OrganisationUsagePanelProps) => {
const [selectedPeriod, setSelectedPeriod] = useState<string | undefined>(() => monthlyStats[0]?.period);
const selectedStat = monthlyStats.find((stat) => stat.period === selectedPeriod) ?? monthlyStats[0];
// Resetting a counter only affects the current month (the server hardcodes the
// current period), so only offer the reset action when viewing the current month.
const isCurrentPeriod = selectedStat?.period === currentMonthlyPeriod();
const rows = [
{
counter: 'document' as const,
label: <Trans>Documents</Trans>,
used: monthlyStats[0]?.documentCount ?? 0,
used: selectedStat?.documentCount ?? 0,
effectiveLimit: organisationClaim.documentQuota,
},
{
counter: 'email' as const,
label: <Trans>Emails</Trans>,
used: monthlyStats[0]?.emailCount ?? 0,
used: selectedStat?.emailCount ?? 0,
effectiveLimit: organisationClaim.emailQuota,
},
{
counter: 'api' as const,
label: <Trans>API requests</Trans>,
used: monthlyStats[0]?.apiCount ?? 0,
used: selectedStat?.apiCount ?? 0,
effectiveLimit: organisationClaim.apiQuota,
},
];
// Todo: This may not show if the organisation has no usage data for the current month.
return (
<div className="space-y-4 rounded-md border p-4">
<div>
<div className="flex items-center justify-between gap-2">
<h3 className="font-medium text-sm">
<Trans>Usage for period: {monthlyStats[0]?.period || 'N/A'}</Trans>
<Trans>Usage for period: {selectedStat?.period || 'N/A'}</Trans>
</h3>
{monthlyStats.length > 0 && (
<Select value={selectedStat?.period} onValueChange={setSelectedPeriod}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{monthlyStats.map((stat) => (
<SelectItem key={stat.period} value={stat.period}>
{stat.period}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{rows.map((row) => {
@@ -67,7 +95,7 @@ export const OrganisationUsagePanel = ({
{row.effectiveLimit && row.effectiveLimit > 0 ? <Progress className="h-2 w-full" value={percent} /> : null}
{monthlyStats[0] && (
{selectedStat && isCurrentPeriod && (
<div className="flex w-full justify-end pt-1">
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
</div>
@@ -75,6 +103,15 @@ export const OrganisationUsagePanel = ({
</div>
);
})}
<div className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span>
<Trans>Reports</Trans>
</span>
<span className="text-muted-foreground">{selectedStat?.emailReports ?? 0}</span>
</div>
</div>
</div>
);
};
@@ -12,11 +12,17 @@ import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from 'lucide-react
import { useMemo } from 'react';
import { Link, useSearchParams } from 'react-router';
type OrderByColumn = 'documentCount' | 'emailCount' | 'apiCount' | 'totalCount';
type OrderByColumn = 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports' | 'totalCount';
type OrderByDirection = 'asc' | 'desc';
const parseOrderByColumn = (value: string | undefined): OrderByColumn | undefined => {
if (value === 'documentCount' || value === 'emailCount' || value === 'apiCount' || value === 'totalCount') {
if (
value === 'documentCount' ||
value === 'emailCount' ||
value === 'apiCount' ||
value === 'emailReports' ||
value === 'totalCount'
) {
return value;
}
@@ -27,10 +33,38 @@ const parseOrderByDirection = (value: string | undefined): OrderByDirection => {
return value === 'asc' ? 'asc' : 'desc';
};
export const AdminOrganisationStatsTable = () => {
/**
* Number of days to divide the period's usage by to get a per-day average.
*
* For the in-progress (current) month we divide by today's UTC day-of-month so the
* average reflects elapsed days only. For a fully-elapsed past month we divide by the
* total number of days in that month.
*/
const getPeriodDivisor = (period: string): number => {
if (period === currentMonthlyPeriod()) {
return new Date().getUTCDate();
}
const [yearStr, monthStr] = period.split('-');
const year = Number(yearStr);
const month = Number(monthStr);
if (Number.isNaN(year) || Number.isNaN(month)) {
return new Date().getUTCDate();
}
// Day 0 of the following month resolves to the last day of `month`.
return new Date(Date.UTC(year, month, 0)).getUTCDate();
};
type AdminOrganisationStatsTableProps = {
showDailyAverages?: boolean;
};
export const AdminOrganisationStatsTable = ({ showDailyAverages = true }: AdminOrganisationStatsTableProps) => {
const { t } = useLingui();
const [searchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
@@ -61,10 +95,17 @@ export const AdminOrganisationStatsTable = () => {
const handleColumnSort = (column: OrderByColumn) => {
const nextDirection = orderByColumn === column && orderByDirection === 'desc' ? 'asc' : 'desc';
updateSearchParams({
orderByColumn: column,
orderByDirection: nextDirection,
page: 1,
// Use the functional updater so we merge onto the latest params. Reading the
// captured `searchParams` here would drop filters (e.g. claimId) that changed
// after this handler was memoised into the column definitions.
setSearchParams((previous) => {
const next = new URLSearchParams(previous);
next.set('orderByColumn', column);
next.set('orderByDirection', nextDirection);
next.set('page', '1');
return next;
});
};
@@ -76,6 +117,27 @@ export const AdminOrganisationStatsTable = () => {
};
const columns = useMemo(() => {
const divisor = getPeriodDivisor(period);
const formatPerDay = (used: number) => {
const perDay = divisor > 0 ? used / divisor : 0;
const rounded = Math.round(perDay * 10) / 10;
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
};
const renderUsageCell = (used: number, quota: number | null) => {
if (showDailyAverages) {
return <span>~{formatPerDay(used)}/day</span>;
}
return (
<span>
{used} / {quota === null ? '∞' : quota}
</span>
);
};
const sortableHeader = (label: string, column: OrderByColumn) => (
<button
type="button"
@@ -100,7 +162,7 @@ export const AdminOrganisationStatsTable = () => {
header: t`Organisation`,
accessorKey: 'organisationName',
cell: ({ row }) => (
<Link to={`/admin/organisations/${row.original.organisationId}`} className="hover:underline">
<Link to={`/admin/organisations/${row.original.organisationId}`} className="text-xs hover:underline">
{row.original.organisationName}
</Link>
),
@@ -108,27 +170,32 @@ export const AdminOrganisationStatsTable = () => {
{
header: t`Claim`,
accessorKey: 'originalClaimId',
cell: ({ row }) => <span className="text-muted-foreground">{row.original.originalClaimId ?? '—'}</span>,
cell: ({ row }) => <span className="text-muted-foreground text-xs">{row.original.originalClaimId ?? '—'}</span>,
},
{
header: t`Period`,
accessorKey: 'period',
cell: ({ row }) => row.original.period,
cell: ({ row }) => <span className="text-xs">{row.original.period}</span>,
},
{
header: () => sortableHeader(t`Documents`, 'documentCount'),
accessorKey: 'documentCount',
cell: ({ row }) => row.original.documentCount,
cell: ({ row }) => renderUsageCell(row.original.documentCount, row.original.documentQuota),
},
{
header: () => sortableHeader(t`Emails`, 'emailCount'),
accessorKey: 'emailCount',
cell: ({ row }) => row.original.emailCount,
cell: ({ row }) => renderUsageCell(row.original.emailCount, row.original.emailQuota),
},
{
header: () => sortableHeader(t`API`, 'apiCount'),
accessorKey: 'apiCount',
cell: ({ row }) => row.original.apiCount,
cell: ({ row }) => renderUsageCell(row.original.apiCount, row.original.apiQuota),
},
{
header: () => sortableHeader(t`Reports`, 'emailReports'),
accessorKey: 'emailReports',
cell: ({ row }) => row.original.emailReports,
},
{
header: () => sortableHeader(t`Total`, 'totalCount'),
@@ -136,14 +203,19 @@ export const AdminOrganisationStatsTable = () => {
cell: ({ row }) => <span className="font-medium">{row.original.totalCount}</span>,
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
// `searchParams` must be a dependency: `handleColumnSort` closes over `setSearchParams`,
// whose functional updater is bound to the `searchParams` captured at creation time.
// Without this, changing a filter (e.g. claimId) wouldn't refresh the memoised handler,
// and sorting would merge onto stale params and drop the active filter.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [t, orderByColumn, orderByDirection]);
}, [t, orderByColumn, orderByDirection, period, showDailyAverages, searchParams]);
return (
<div>
<DataTable
columns={columns}
data={results.data}
rowClassName="text-xs"
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
@@ -177,6 +249,9 @@ export const AdminOrganisationStatsTable = () => {
<TableCell>
<Skeleton className="h-4 w-10 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-10 rounded-full" />
</TableCell>
</>
),
}}
@@ -131,7 +131,7 @@ export default function AdminDocumentsPage() {
<div>
<Input
type="search"
placeholder={_(msg`Search by document title`)}
placeholder={_(msg`Search by document title, team:123 or user:123`)}
value={term}
onChange={(e) => setTerm(e.target.value)}
/>
@@ -3,6 +3,7 @@ import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Input } from '@documenso/ui/primitives/input';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Trans, useLingui } from '@lingui/react/macro';
import { useEffect, useMemo, useState } from 'react';
@@ -52,6 +53,8 @@ export default function OrganisationStats() {
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const [displayMode, setDisplayMode] = useState<'quotas' | 'averages'>('quotas');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
const periodOptions = useMemo(() => generatePeriodOptions(), []);
@@ -157,9 +160,29 @@ export default function OrganisationStats() {
</div>
<div className="mt-4">
<AdminOrganisationStatsTable />
<AdminOrganisationStatsTable showDailyAverages={displayMode === 'averages'} />
</div>
<RadioGroup
value={displayMode}
onValueChange={(value) => setDisplayMode(value === 'averages' ? 'averages' : 'quotas')}
className="mt-4 flex flex-col gap-3 rounded-lg border border-border p-4"
>
<div className="flex items-center gap-2">
<RadioGroupItem id="display-quotas" value="quotas" />
<label htmlFor="display-quotas" className="text-muted-foreground text-sm">
<Trans>Show quotas for documents, emails and API usages</Trans>
</label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem id="display-averages" value="averages" />
<label htmlFor="display-averages" className="text-muted-foreground text-sm">
<Trans>Show daily averages for documents, emails and API usages</Trans>
</label>
</div>
</RadioGroup>
<Alert variant="neutral" className="mt-4">
<AlertDescription>
<Trans>
@@ -0,0 +1,92 @@
import { SUPPORT_EMAIL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import type { Route } from './+types/report.$token';
export async function loader({ params }: Route.LoaderArgs) {
const { token } = params;
if (!token) {
throw new Response('Not Found', { status: 404 });
}
// Only validate the token on GET. The report itself is performed by an explicit
// mutation (triggered by the recipient clicking the button), so an automated email
// link scanner / prefetcher cannot register a report simply by fetching the URL.
const recipient = await prisma.recipient.findFirst({
where: { token },
select: { id: true },
});
if (!recipient) {
throw new Response('Not Found', { status: 404 });
}
return {
token,
};
}
export default function ReportSenderPage({ loaderData }: Route.ComponentProps) {
const { token } = loaderData;
const { t } = useLingui();
const { toast } = useToast();
const [isReported, setIsReported] = useState(false);
const { mutate: reportSender, isPending } = trpc.envelope.recipient.report.useMutation({
onSuccess: () => setIsReported(true),
onError: () => {
toast({
title: t`Something went wrong`,
description: t`We were unable to report this sender at this time. Please try again later.`,
variant: 'destructive',
});
},
});
if (isReported) {
return (
<div className="-mx-4 flex flex-col items-center px-4 pt-16 md:-mx-8 md:px-8 lg:pt-20 xl:pt-28">
<h1 className="max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl">
<Trans>Sender reported</Trans>
</h1>
<p className="mt-4 max-w-[60ch] text-center text-muted-foreground leading-normal">
<Trans>
Thank you for letting us know, we have flagged this sender for review. If you have any concerns please feel
free to reach out to our{' '}
<a className="text-documenso-700 underline" href={`mailto:${SUPPORT_EMAIL}`}>
support team
</a>
.
</Trans>
</p>
</div>
);
}
return (
<div className="-mx-4 flex flex-col items-center px-4 pt-16 md:-mx-8 md:px-8 lg:pt-20 xl:pt-28">
<h1 className="max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl">
<Trans>Report this sender?</Trans>
</h1>
<p className="mt-4 max-w-[60ch] text-center text-muted-foreground leading-normal">
<Trans>
If you did not expect this email or believe it is spam, you can report the sender to our team for review.
</Trans>
</p>
<Button className="mt-6" loading={isPending} onClick={() => reportSender({ token })}>
<Trans>Report sender</Trans>
</Button>
</div>
);
}