mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
fix: add email reporting (#2918)
This commit is contained in:
@@ -1,13 +1,19 @@
|
|||||||
|
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
|
||||||
import { Progress } from '@documenso/ui/primitives/progress';
|
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 { Trans } from '@lingui/react/macro';
|
||||||
import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client';
|
import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client';
|
||||||
|
import { useState } from 'react';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
import { OrganisationUsageResetButton } from './organisation-usage-reset-button';
|
import { OrganisationUsageResetButton } from './organisation-usage-reset-button';
|
||||||
|
|
||||||
type OrganisationUsagePanelProps = {
|
type OrganisationUsagePanelProps = {
|
||||||
organisationId: string;
|
organisationId: string;
|
||||||
monthlyStats: Pick<OrganisationMonthlyStat, 'period' | 'documentCount' | 'emailCount' | 'apiCount'>[];
|
monthlyStats: Pick<
|
||||||
|
OrganisationMonthlyStat,
|
||||||
|
'period' | 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports'
|
||||||
|
>[];
|
||||||
organisationClaim: OrganisationClaim;
|
organisationClaim: OrganisationClaim;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -16,34 +22,56 @@ export const OrganisationUsagePanel = ({
|
|||||||
monthlyStats,
|
monthlyStats,
|
||||||
organisationClaim,
|
organisationClaim,
|
||||||
}: OrganisationUsagePanelProps) => {
|
}: 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 = [
|
const rows = [
|
||||||
{
|
{
|
||||||
counter: 'document' as const,
|
counter: 'document' as const,
|
||||||
label: <Trans>Documents</Trans>,
|
label: <Trans>Documents</Trans>,
|
||||||
used: monthlyStats[0]?.documentCount ?? 0,
|
used: selectedStat?.documentCount ?? 0,
|
||||||
effectiveLimit: organisationClaim.documentQuota,
|
effectiveLimit: organisationClaim.documentQuota,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
counter: 'email' as const,
|
counter: 'email' as const,
|
||||||
label: <Trans>Emails</Trans>,
|
label: <Trans>Emails</Trans>,
|
||||||
used: monthlyStats[0]?.emailCount ?? 0,
|
used: selectedStat?.emailCount ?? 0,
|
||||||
effectiveLimit: organisationClaim.emailQuota,
|
effectiveLimit: organisationClaim.emailQuota,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
counter: 'api' as const,
|
counter: 'api' as const,
|
||||||
label: <Trans>API requests</Trans>,
|
label: <Trans>API requests</Trans>,
|
||||||
used: monthlyStats[0]?.apiCount ?? 0,
|
used: selectedStat?.apiCount ?? 0,
|
||||||
effectiveLimit: organisationClaim.apiQuota,
|
effectiveLimit: organisationClaim.apiQuota,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Todo: This may not show if the organisation has no usage data for the current month.
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 rounded-md border p-4">
|
<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">
|
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{rows.map((row) => {
|
{rows.map((row) => {
|
||||||
@@ -67,7 +95,7 @@ export const OrganisationUsagePanel = ({
|
|||||||
|
|
||||||
{row.effectiveLimit && row.effectiveLimit > 0 ? <Progress className="h-2 w-full" value={percent} /> : null}
|
{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">
|
<div className="flex w-full justify-end pt-1">
|
||||||
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
|
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
|
||||||
</div>
|
</div>
|
||||||
@@ -75,6 +103,15 @@ export const OrganisationUsagePanel = ({
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,11 +12,17 @@ import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from 'lucide-react
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Link, useSearchParams } from 'react-router';
|
import { Link, useSearchParams } from 'react-router';
|
||||||
|
|
||||||
type OrderByColumn = 'documentCount' | 'emailCount' | 'apiCount' | 'totalCount';
|
type OrderByColumn = 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports' | 'totalCount';
|
||||||
type OrderByDirection = 'asc' | 'desc';
|
type OrderByDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
const parseOrderByColumn = (value: string | undefined): OrderByColumn | undefined => {
|
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;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,10 +33,38 @@ const parseOrderByDirection = (value: string | undefined): OrderByDirection => {
|
|||||||
return value === 'asc' ? 'asc' : 'desc';
|
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 { t } = useLingui();
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
||||||
@@ -61,10 +95,17 @@ export const AdminOrganisationStatsTable = () => {
|
|||||||
const handleColumnSort = (column: OrderByColumn) => {
|
const handleColumnSort = (column: OrderByColumn) => {
|
||||||
const nextDirection = orderByColumn === column && orderByDirection === 'desc' ? 'asc' : 'desc';
|
const nextDirection = orderByColumn === column && orderByDirection === 'desc' ? 'asc' : 'desc';
|
||||||
|
|
||||||
updateSearchParams({
|
// Use the functional updater so we merge onto the latest params. Reading the
|
||||||
orderByColumn: column,
|
// captured `searchParams` here would drop filters (e.g. claimId) that changed
|
||||||
orderByDirection: nextDirection,
|
// after this handler was memoised into the column definitions.
|
||||||
page: 1,
|
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 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) => (
|
const sortableHeader = (label: string, column: OrderByColumn) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -100,7 +162,7 @@ export const AdminOrganisationStatsTable = () => {
|
|||||||
header: t`Organisation`,
|
header: t`Organisation`,
|
||||||
accessorKey: 'organisationName',
|
accessorKey: 'organisationName',
|
||||||
cell: ({ row }) => (
|
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}
|
{row.original.organisationName}
|
||||||
</Link>
|
</Link>
|
||||||
),
|
),
|
||||||
@@ -108,27 +170,32 @@ export const AdminOrganisationStatsTable = () => {
|
|||||||
{
|
{
|
||||||
header: t`Claim`,
|
header: t`Claim`,
|
||||||
accessorKey: 'originalClaimId',
|
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`,
|
header: t`Period`,
|
||||||
accessorKey: 'period',
|
accessorKey: 'period',
|
||||||
cell: ({ row }) => row.original.period,
|
cell: ({ row }) => <span className="text-xs">{row.original.period}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: () => sortableHeader(t`Documents`, 'documentCount'),
|
header: () => sortableHeader(t`Documents`, 'documentCount'),
|
||||||
accessorKey: 'documentCount',
|
accessorKey: 'documentCount',
|
||||||
cell: ({ row }) => row.original.documentCount,
|
cell: ({ row }) => renderUsageCell(row.original.documentCount, row.original.documentQuota),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: () => sortableHeader(t`Emails`, 'emailCount'),
|
header: () => sortableHeader(t`Emails`, 'emailCount'),
|
||||||
accessorKey: 'emailCount',
|
accessorKey: 'emailCount',
|
||||||
cell: ({ row }) => row.original.emailCount,
|
cell: ({ row }) => renderUsageCell(row.original.emailCount, row.original.emailQuota),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: () => sortableHeader(t`API`, 'apiCount'),
|
header: () => sortableHeader(t`API`, 'apiCount'),
|
||||||
accessorKey: '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'),
|
header: () => sortableHeader(t`Total`, 'totalCount'),
|
||||||
@@ -136,14 +203,19 @@ export const AdminOrganisationStatsTable = () => {
|
|||||||
cell: ({ row }) => <span className="font-medium">{row.original.totalCount}</span>,
|
cell: ({ row }) => <span className="font-medium">{row.original.totalCount}</span>,
|
||||||
},
|
},
|
||||||
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
] 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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [t, orderByColumn, orderByDirection]);
|
}, [t, orderByColumn, orderByDirection, period, showDailyAverages, searchParams]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={results.data}
|
data={results.data}
|
||||||
|
rowClassName="text-xs"
|
||||||
perPage={results.perPage}
|
perPage={results.perPage}
|
||||||
currentPage={results.currentPage}
|
currentPage={results.currentPage}
|
||||||
totalPages={results.totalPages}
|
totalPages={results.totalPages}
|
||||||
@@ -177,6 +249,9 @@ export const AdminOrganisationStatsTable = () => {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<Skeleton className="h-4 w-10 rounded-full" />
|
<Skeleton className="h-4 w-10 rounded-full" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-10 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export default function AdminDocumentsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder={_(msg`Search by document title`)}
|
placeholder={_(msg`Search by document title, team:123 or user:123`)}
|
||||||
value={term}
|
value={term}
|
||||||
onChange={(e) => setTerm(e.target.value)}
|
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 { trpc } from '@documenso/trpc/react';
|
||||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
@@ -52,6 +53,8 @@ export default function OrganisationStats() {
|
|||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
|
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
|
||||||
|
|
||||||
|
const [displayMode, setDisplayMode] = useState<'quotas' | 'averages'>('quotas');
|
||||||
|
|
||||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
|
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
|
||||||
|
|
||||||
const periodOptions = useMemo(() => generatePeriodOptions(), []);
|
const periodOptions = useMemo(() => generatePeriodOptions(), []);
|
||||||
@@ -157,9 +160,29 @@ export default function OrganisationStats() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<AdminOrganisationStatsTable />
|
<AdminOrganisationStatsTable showDailyAverages={displayMode === 'averages'} />
|
||||||
</div>
|
</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">
|
<Alert variant="neutral" className="mt-4">
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<Trans>
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
const getEmailReports = async (organisationId: string) => {
|
||||||
|
const stat = await prisma.organisationMonthlyStat.findUnique({
|
||||||
|
where: {
|
||||||
|
organisationId_period: {
|
||||||
|
organisationId,
|
||||||
|
period: currentMonthlyPeriod(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { emailReports: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return stat?.emailReports ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
test('[REPORT_SENDER]: only reports the sender after the button is clicked', async ({ page }) => {
|
||||||
|
const { user, team, organisation } = await seedUser();
|
||||||
|
|
||||||
|
const document = await seedPendingDocument(user, team.id, ['recipient@documenso.com']);
|
||||||
|
const token = document.recipients[0].token;
|
||||||
|
|
||||||
|
expect(await getEmailReports(organisation.id)).toBe(0);
|
||||||
|
|
||||||
|
await page.goto(`/report/${token}`);
|
||||||
|
|
||||||
|
// Visiting the page (GET) must not register a report.
|
||||||
|
await expect(page.getByRole('heading', { name: 'Report this sender?' })).toBeVisible();
|
||||||
|
expect(await getEmailReports(organisation.id)).toBe(0);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Report sender' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sender reported' })).toBeVisible();
|
||||||
|
|
||||||
|
expect(await getEmailReports(organisation.id)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[REPORT_SENDER]: does not double count within the rate limit window', async ({ page }) => {
|
||||||
|
test.skip(process.env.DANGEROUS_BYPASS_RATE_LIMITS === 'true', 'Rate limits are bypassed');
|
||||||
|
|
||||||
|
const { user, team, organisation } = await seedUser();
|
||||||
|
|
||||||
|
const document = await seedPendingDocument(user, team.id, ['recipient@documenso.com']);
|
||||||
|
const token = document.recipients[0].token;
|
||||||
|
|
||||||
|
await page.goto(`/report/${token}`);
|
||||||
|
await page.getByRole('button', { name: 'Report sender' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sender reported' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto(`/report/${token}`);
|
||||||
|
await page.getByRole('button', { name: 'Report sender' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sender reported' })).toBeVisible();
|
||||||
|
|
||||||
|
expect(await getEmailReports(organisation.id)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[REPORT_SENDER]: returns 404 for an invalid token', async ({ page }) => {
|
||||||
|
const response = await page.goto('/report/not-a-real-token');
|
||||||
|
|
||||||
|
expect(response?.status()).toBe(404);
|
||||||
|
});
|
||||||
@@ -5,13 +5,26 @@ import { useBranding } from '../providers/branding';
|
|||||||
|
|
||||||
export type TemplateFooterProps = {
|
export type TemplateFooterProps = {
|
||||||
isDocument?: boolean;
|
isDocument?: boolean;
|
||||||
|
reportUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TemplateFooter = ({ isDocument = true }: TemplateFooterProps) => {
|
export const TemplateFooter = ({ isDocument = true, reportUrl }: TemplateFooterProps) => {
|
||||||
const branding = useBranding();
|
const branding = useBranding();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section>
|
<Section>
|
||||||
|
{reportUrl && (
|
||||||
|
<Text className="my-4 text-base text-slate-400">
|
||||||
|
<Trans>
|
||||||
|
Did not expect this email?{' '}
|
||||||
|
<Link className="text-[#7AC455]" href={reportUrl}>
|
||||||
|
Click here to report the sender
|
||||||
|
</Link>
|
||||||
|
. Never sign a document you don't recognize or weren't expecting.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
{isDocument && !branding.brandingHidePoweredBy && (
|
{isDocument && !branding.brandingHidePoweredBy && (
|
||||||
<Text className="my-4 text-base text-slate-400">
|
<Text className="my-4 text-base text-slate-400">
|
||||||
<Trans>
|
<Trans>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { TemplateFooter } from '../template-components/template-footer';
|
|||||||
|
|
||||||
export type DocumentCompletedEmailTemplateProps = Partial<TemplateDocumentCompletedProps> & {
|
export type DocumentCompletedEmailTemplateProps = Partial<TemplateDocumentCompletedProps> & {
|
||||||
customBody?: string;
|
customBody?: string;
|
||||||
|
reportUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentCompletedEmailTemplate = ({
|
export const DocumentCompletedEmailTemplate = ({
|
||||||
@@ -16,6 +17,7 @@ export const DocumentCompletedEmailTemplate = ({
|
|||||||
documentName = 'Open Source Pledge.pdf',
|
documentName = 'Open Source Pledge.pdf',
|
||||||
assetBaseUrl = 'http://localhost:3002',
|
assetBaseUrl = 'http://localhost:3002',
|
||||||
customBody,
|
customBody,
|
||||||
|
reportUrl,
|
||||||
}: DocumentCompletedEmailTemplateProps) => {
|
}: DocumentCompletedEmailTemplateProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const branding = useBranding();
|
const branding = useBranding();
|
||||||
@@ -51,7 +53,7 @@ export const DocumentCompletedEmailTemplate = ({
|
|||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
<Container className="mx-auto max-w-xl">
|
<Container className="mx-auto max-w-xl">
|
||||||
<TemplateFooter />
|
<TemplateFooter reportUrl={reportUrl} />
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
</Body>
|
</Body>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInvitePro
|
|||||||
teamEmail?: string;
|
teamEmail?: string;
|
||||||
includeSenderDetails?: boolean;
|
includeSenderDetails?: boolean;
|
||||||
organisationType?: OrganisationType;
|
organisationType?: OrganisationType;
|
||||||
|
reportUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentInviteEmailTemplate = ({
|
export const DocumentInviteEmailTemplate = ({
|
||||||
@@ -34,6 +35,7 @@ export const DocumentInviteEmailTemplate = ({
|
|||||||
teamName = '',
|
teamName = '',
|
||||||
includeSenderDetails,
|
includeSenderDetails,
|
||||||
organisationType,
|
organisationType,
|
||||||
|
reportUrl,
|
||||||
}: DocumentInviteEmailTemplateProps) => {
|
}: DocumentInviteEmailTemplateProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const branding = useBranding();
|
const branding = useBranding();
|
||||||
@@ -114,7 +116,7 @@ export const DocumentInviteEmailTemplate = ({
|
|||||||
<Hr className="mx-auto mt-12 max-w-xl" />
|
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||||
|
|
||||||
<Container className="mx-auto max-w-xl">
|
<Container className="mx-auto max-w-xl">
|
||||||
<TemplateFooter />
|
<TemplateFooter reportUrl={reportUrl} />
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
</Body>
|
</Body>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export type DocumentReminderEmailTemplateProps = {
|
|||||||
assetBaseUrl?: string;
|
assetBaseUrl?: string;
|
||||||
customBody?: string;
|
customBody?: string;
|
||||||
role: RecipientRole;
|
role: RecipientRole;
|
||||||
|
reportUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentReminderEmailTemplate = ({
|
export const DocumentReminderEmailTemplate = ({
|
||||||
@@ -25,6 +26,7 @@ export const DocumentReminderEmailTemplate = ({
|
|||||||
assetBaseUrl = 'http://localhost:3002',
|
assetBaseUrl = 'http://localhost:3002',
|
||||||
customBody,
|
customBody,
|
||||||
role = RecipientRole.SIGNER,
|
role = RecipientRole.SIGNER,
|
||||||
|
reportUrl,
|
||||||
}: DocumentReminderEmailTemplateProps) => {
|
}: DocumentReminderEmailTemplateProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const branding = useBranding();
|
const branding = useBranding();
|
||||||
@@ -75,7 +77,7 @@ export const DocumentReminderEmailTemplate = ({
|
|||||||
<Hr className="mx-auto mt-12 max-w-xl" />
|
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||||
|
|
||||||
<Container className="mx-auto max-w-xl">
|
<Container className="mx-auto max-w-xl">
|
||||||
<TemplateFooter />
|
<TemplateFooter reportUrl={reportUrl} />
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
</Body>
|
</Body>
|
||||||
|
|||||||
@@ -211,6 +211,8 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCompletedEmai
|
|||||||
};
|
};
|
||||||
|
|
||||||
const downloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}/complete`;
|
const downloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}/complete`;
|
||||||
|
const reportUrl =
|
||||||
|
recipient.role === RecipientRole.CC ? `${NEXT_PUBLIC_WEBAPP_URL()}/report/${recipient.token}` : undefined;
|
||||||
|
|
||||||
const template = createElement(DocumentCompletedEmailTemplate, {
|
const template = createElement(DocumentCompletedEmailTemplate, {
|
||||||
documentName: envelope.title,
|
documentName: envelope.title,
|
||||||
@@ -220,6 +222,7 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCompletedEmai
|
|||||||
isDirectTemplate && envelope.documentMeta?.message
|
isDirectTemplate && envelope.documentMeta?.message
|
||||||
? renderCustomEmailTemplate(envelope.documentMeta.message, customEmailTemplate)
|
? renderCustomEmailTemplate(envelope.documentMeta.message, customEmailTemplate)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
reportUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [html, text] = await Promise.all([
|
const [html, text] = await Promise.all([
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini
|
|||||||
|
|
||||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||||
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
|
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
|
||||||
|
const reportUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/report/${recipient.token}`;
|
||||||
|
|
||||||
const template = createElement(DocumentInviteEmailTemplate, {
|
const template = createElement(DocumentInviteEmailTemplate, {
|
||||||
documentName: envelope.title,
|
documentName: envelope.title,
|
||||||
@@ -180,6 +181,7 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini
|
|||||||
teamName: team?.name,
|
teamName: team?.name,
|
||||||
teamEmail: team?.teamEmail?.email,
|
teamEmail: team?.teamEmail?.email,
|
||||||
includeSenderDetails: settings.includeSenderDetails,
|
includeSenderDetails: settings.includeSenderDetails,
|
||||||
|
reportUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isRecipientEmailValidForSending(recipient)) {
|
if (isRecipientEmailValidForSending(recipient)) {
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ export const run = async ({ payload, io }: { payload: TProcessSigningReminderJob
|
|||||||
|
|
||||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||||
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
|
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
|
||||||
|
const reportUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/report/${recipient.token}`;
|
||||||
|
|
||||||
// Meter reminder emails against the organisation email quota/stats. Reminders
|
// Meter reminder emails against the organisation email quota/stats. Reminders
|
||||||
// are unsolicited (the recipient didn't opt in to them) and can recur, so they
|
// are unsolicited (the recipient didn't opt in to them) and can recur, so they
|
||||||
@@ -188,6 +189,7 @@ export const run = async ({ payload, io }: { payload: TProcessSigningReminderJob
|
|||||||
signDocumentLink,
|
signDocumentLink,
|
||||||
customBody: emailMessage,
|
customBody: emailMessage,
|
||||||
role: recipient.role,
|
role: recipient.role,
|
||||||
|
reportUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [html, text] = await Promise.all([
|
const [html, text] = await Promise.all([
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { EnvelopeType, type Prisma } from '@prisma/client';
|
import { EnvelopeType, type Prisma } from '@prisma/client';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type { FindResultResponse } from '../../types/search-params';
|
import type { FindResultResponse } from '../../types/search-params';
|
||||||
|
|
||||||
@@ -9,6 +10,16 @@ export interface AdminFindDocumentsOptions {
|
|||||||
perPage?: number;
|
perPage?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ZPositiveIntegerSchema = z.coerce.number().int().positive();
|
||||||
|
|
||||||
|
const emptyResponse = {
|
||||||
|
data: [],
|
||||||
|
count: 0,
|
||||||
|
currentPage: 1,
|
||||||
|
perPage: 10,
|
||||||
|
totalPages: 0,
|
||||||
|
};
|
||||||
|
|
||||||
export const adminFindDocuments = async ({ query, page = 1, perPage = 10 }: AdminFindDocumentsOptions) => {
|
export const adminFindDocuments = async ({ query, page = 1, perPage = 10 }: AdminFindDocumentsOptions) => {
|
||||||
let termFilters: Prisma.EnvelopeWhereInput | undefined = !query
|
let termFilters: Prisma.EnvelopeWhereInput | undefined = !query
|
||||||
? undefined
|
? undefined
|
||||||
@@ -19,7 +30,35 @@ export const adminFindDocuments = async ({ query, page = 1, perPage = 10 }: Admi
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (query && query.startsWith('envelope_')) {
|
if (query?.startsWith('user:')) {
|
||||||
|
const parsedUserId = ZPositiveIntegerSchema.safeParse(query.slice('user:'.length));
|
||||||
|
|
||||||
|
if (parsedUserId.success) {
|
||||||
|
termFilters = {
|
||||||
|
userId: {
|
||||||
|
equals: parsedUserId.data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return emptyResponse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query?.startsWith('team:')) {
|
||||||
|
const parsedTeamId = ZPositiveIntegerSchema.safeParse(query.slice('team:'.length));
|
||||||
|
|
||||||
|
if (parsedTeamId.success) {
|
||||||
|
termFilters = {
|
||||||
|
teamId: {
|
||||||
|
equals: parsedTeamId.data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return emptyResponse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query && query?.startsWith('envelope_')) {
|
||||||
termFilters = {
|
termFilters = {
|
||||||
id: {
|
id: {
|
||||||
equals: query,
|
equals: query,
|
||||||
@@ -27,7 +66,7 @@ export const adminFindDocuments = async ({ query, page = 1, perPage = 10 }: Admi
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query && query.startsWith('document_')) {
|
if (query && query?.startsWith('document_')) {
|
||||||
termFilters = {
|
termFilters = {
|
||||||
secondaryId: {
|
secondaryId: {
|
||||||
equals: query,
|
equals: query,
|
||||||
|
|||||||
@@ -210,6 +210,7 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
|||||||
|
|
||||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||||
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
|
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
|
||||||
|
const reportUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/report/${recipient.token}`;
|
||||||
|
|
||||||
const template = createElement(DocumentInviteEmailTemplate, {
|
const template = createElement(DocumentInviteEmailTemplate, {
|
||||||
documentName: envelope.title,
|
documentName: envelope.title,
|
||||||
@@ -225,6 +226,7 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
|||||||
selfSigner,
|
selfSigner,
|
||||||
organisationType,
|
organisationType,
|
||||||
teamName: envelope.team?.name,
|
teamName: envelope.team?.name,
|
||||||
|
reportUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [html, text] = await Promise.all([
|
const [html, text] = await Promise.all([
|
||||||
|
|||||||
@@ -66,6 +66,12 @@ export const linkOrgAccountRateLimit = createRateLimit({
|
|||||||
window: '1h',
|
window: '1h',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const reportSenderRateLimit = createRateLimit({
|
||||||
|
action: 'recipient.report-sender',
|
||||||
|
max: 1,
|
||||||
|
window: '7d',
|
||||||
|
});
|
||||||
|
|
||||||
// ---- API (Tier 4 - Standard) ----
|
// ---- API (Tier 4 - Standard) ----
|
||||||
|
|
||||||
export const apiV1RateLimit = createRateLimit({
|
export const apiV1RateLimit = createRateLimit({
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "OrganisationMonthlyStat" ADD COLUMN "emailReports" INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -322,6 +322,7 @@ model OrganisationMonthlyStat {
|
|||||||
documentCount Int @default(0)
|
documentCount Int @default(0)
|
||||||
emailCount Int @default(0)
|
emailCount Int @default(0)
|
||||||
apiCount Int @default(0)
|
apiCount Int @default(0)
|
||||||
|
emailReports Int @default(0)
|
||||||
|
|
||||||
@@unique([organisationId, period])
|
@@unique([organisationId, period])
|
||||||
@@index([organisationId])
|
@@index([organisationId])
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ type FindOrganisationStatsOptions = {
|
|||||||
claimId?: string;
|
claimId?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
perPage?: number;
|
perPage?: number;
|
||||||
orderByColumn?: 'documentCount' | 'emailCount' | 'apiCount' | 'totalCount';
|
orderByColumn?: 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports' | 'totalCount';
|
||||||
orderByDirection?: 'asc' | 'desc';
|
orderByDirection?: 'asc' | 'desc';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -91,6 +91,10 @@ export const findOrganisationStats = async ({
|
|||||||
'OrganisationMonthlyStat.documentCount as documentCount',
|
'OrganisationMonthlyStat.documentCount as documentCount',
|
||||||
'OrganisationMonthlyStat.emailCount as emailCount',
|
'OrganisationMonthlyStat.emailCount as emailCount',
|
||||||
'OrganisationMonthlyStat.apiCount as apiCount',
|
'OrganisationMonthlyStat.apiCount as apiCount',
|
||||||
|
'OrganisationMonthlyStat.emailReports as emailReports',
|
||||||
|
'OrganisationClaim.documentQuota as documentQuota',
|
||||||
|
'OrganisationClaim.emailQuota as emailQuota',
|
||||||
|
'OrganisationClaim.apiQuota as apiQuota',
|
||||||
totalCountExpression.as('totalCount'),
|
totalCountExpression.as('totalCount'),
|
||||||
eb.fn.countAll().over().as('totalRows'),
|
eb.fn.countAll().over().as('totalRows'),
|
||||||
])
|
])
|
||||||
@@ -99,6 +103,7 @@ export const findOrganisationStats = async ({
|
|||||||
.with('documentCount', () => qb.orderBy('OrganisationMonthlyStat.documentCount', orderByDirection))
|
.with('documentCount', () => qb.orderBy('OrganisationMonthlyStat.documentCount', orderByDirection))
|
||||||
.with('emailCount', () => qb.orderBy('OrganisationMonthlyStat.emailCount', orderByDirection))
|
.with('emailCount', () => qb.orderBy('OrganisationMonthlyStat.emailCount', orderByDirection))
|
||||||
.with('apiCount', () => qb.orderBy('OrganisationMonthlyStat.apiCount', orderByDirection))
|
.with('apiCount', () => qb.orderBy('OrganisationMonthlyStat.apiCount', orderByDirection))
|
||||||
|
.with('emailReports', () => qb.orderBy('OrganisationMonthlyStat.emailReports', orderByDirection))
|
||||||
.with('totalCount', () => qb.orderBy(totalCountExpression, orderByDirection))
|
.with('totalCount', () => qb.orderBy(totalCountExpression, orderByDirection))
|
||||||
.with(undefined, () =>
|
.with(undefined, () =>
|
||||||
// Default ordering mirrors the desired SQL: email, api, document descending.
|
// Default ordering mirrors the desired SQL: email, api, document descending.
|
||||||
@@ -126,6 +131,10 @@ export const findOrganisationStats = async ({
|
|||||||
documentCount: Number(row.documentCount),
|
documentCount: Number(row.documentCount),
|
||||||
emailCount: Number(row.emailCount),
|
emailCount: Number(row.emailCount),
|
||||||
apiCount: Number(row.apiCount),
|
apiCount: Number(row.apiCount),
|
||||||
|
emailReports: Number(row.emailReports),
|
||||||
|
documentQuota: row.documentQuota === null ? null : Number(row.documentQuota),
|
||||||
|
emailQuota: row.emailQuota === null ? null : Number(row.emailQuota),
|
||||||
|
apiQuota: row.apiQuota === null ? null : Number(row.apiQuota),
|
||||||
totalCount: Number(row.totalCount),
|
totalCount: Number(row.totalCount),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const ZFindOrganisationStatsRequestSchema = ZFindSearchParamsSchema.exten
|
|||||||
.optional(),
|
.optional(),
|
||||||
claimId: z.string().describe('Filter stats by the original subscription claim ID.').optional(),
|
claimId: z.string().describe('Filter stats by the original subscription claim ID.').optional(),
|
||||||
orderByColumn: z
|
orderByColumn: z
|
||||||
.enum(['documentCount', 'emailCount', 'apiCount', 'totalCount'])
|
.enum(['documentCount', 'emailCount', 'apiCount', 'emailReports', 'totalCount'])
|
||||||
.describe('The column to sort by.')
|
.describe('The column to sort by.')
|
||||||
.optional(),
|
.optional(),
|
||||||
orderByDirection: z.enum(['asc', 'desc']).describe('Sort direction.').default('desc'),
|
orderByDirection: z.enum(['asc', 'desc']).describe('Sort direction.').default('desc'),
|
||||||
@@ -26,6 +26,10 @@ export const ZFindOrganisationStatsResponseSchema = ZFindResultResponse.extend({
|
|||||||
documentCount: z.number(),
|
documentCount: z.number(),
|
||||||
emailCount: z.number(),
|
emailCount: z.number(),
|
||||||
apiCount: z.number(),
|
apiCount: z.number(),
|
||||||
|
emailReports: z.number(),
|
||||||
|
documentQuota: z.number().nullable(),
|
||||||
|
emailQuota: z.number().nullable(),
|
||||||
|
apiQuota: z.number().nullable(),
|
||||||
totalCount: z.number(),
|
totalCount: z.number(),
|
||||||
})
|
})
|
||||||
.array(),
|
.array(),
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export const ZGetAdminOrganisationResponseSchema = ZOrganisationSchema.extend({
|
|||||||
documentCount: true,
|
documentCount: true,
|
||||||
emailCount: true,
|
emailCount: true,
|
||||||
apiCount: true,
|
apiCount: true,
|
||||||
|
emailReports: true,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { reportSenderRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
|
||||||
|
import { generateDatabaseId } from '@documenso/lib/universal/id';
|
||||||
|
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { procedure } from '../../trpc';
|
||||||
|
import { ZReportRecipientRequestSchema, ZReportRecipientResponseSchema } from './report-recipient.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NOTE: THIS IS A PUBLIC (UNAUTHENTICATED) PROCEDURE.
|
||||||
|
* Recipients report a sender directly from a link in their email, so no session or
|
||||||
|
* API token is required.
|
||||||
|
*/
|
||||||
|
export const reportRecipientRoute = procedure
|
||||||
|
.input(ZReportRecipientRequestSchema)
|
||||||
|
.output(ZReportRecipientResponseSchema)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const { token } = input;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'Token is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ipAddress } = ctx.metadata.requestMetadata;
|
||||||
|
|
||||||
|
const recipient = await prisma.recipient.findFirst({
|
||||||
|
where: { token },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
envelopeId: true,
|
||||||
|
envelope: {
|
||||||
|
select: {
|
||||||
|
team: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
organisationId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'Recipient could not be found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit to ensure we aren't double reporting by accident.
|
||||||
|
const rateLimitResult = await reportSenderRateLimit.check({
|
||||||
|
ip: ipAddress ?? 'unknown',
|
||||||
|
identifier: `${recipient.envelopeId}:${recipient.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rateLimitResult.isLimited) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const period = currentMonthlyPeriod();
|
||||||
|
const { organisationId } = recipient.envelope.team;
|
||||||
|
|
||||||
|
// Incrementing the stat is a non-critical side effect; fail soft so a transient
|
||||||
|
// DB error never turns reporting into a user-facing error.
|
||||||
|
await prisma.organisationMonthlyStat
|
||||||
|
.upsert({
|
||||||
|
where: {
|
||||||
|
organisationId_period: {
|
||||||
|
organisationId,
|
||||||
|
period,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
emailReports: { increment: 1 },
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id: generateDatabaseId('org_monthly_stat'),
|
||||||
|
organisationId,
|
||||||
|
period,
|
||||||
|
emailReports: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
ctx.logger.error({
|
||||||
|
msg: 'Failed to increment organisation emailReports stat',
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.logger.info({
|
||||||
|
msg: `Email reported. Recipient: ${recipient.id}. Envelope: ${recipient.envelopeId}.`,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const ZReportRecipientRequestSchema = z.object({
|
||||||
|
token: z.string().min(1).describe('The recipient token from the email link used to report the sender.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZReportRecipientResponseSchema = z.void();
|
||||||
|
|
||||||
|
export type TReportRecipientRequest = z.infer<typeof ZReportRecipientRequestSchema>;
|
||||||
@@ -20,6 +20,7 @@ import { updateEnvelopeFieldsRoute } from './envelope-fields/update-envelope-fie
|
|||||||
import { createEnvelopeRecipientsRoute } from './envelope-recipients/create-envelope-recipients';
|
import { createEnvelopeRecipientsRoute } from './envelope-recipients/create-envelope-recipients';
|
||||||
import { deleteEnvelopeRecipientRoute } from './envelope-recipients/delete-envelope-recipient';
|
import { deleteEnvelopeRecipientRoute } from './envelope-recipients/delete-envelope-recipient';
|
||||||
import { getEnvelopeRecipientRoute } from './envelope-recipients/get-envelope-recipient';
|
import { getEnvelopeRecipientRoute } from './envelope-recipients/get-envelope-recipient';
|
||||||
|
import { reportRecipientRoute } from './envelope-recipients/report-recipient';
|
||||||
import { updateEnvelopeRecipientsRoute } from './envelope-recipients/update-envelope-recipients';
|
import { updateEnvelopeRecipientsRoute } from './envelope-recipients/update-envelope-recipients';
|
||||||
import { findEnvelopeAuditLogsRoute } from './find-envelope-audit-logs';
|
import { findEnvelopeAuditLogsRoute } from './find-envelope-audit-logs';
|
||||||
import { findEnvelopesRoute } from './find-envelopes';
|
import { findEnvelopesRoute } from './find-envelopes';
|
||||||
@@ -66,6 +67,7 @@ export const envelopeRouter = router({
|
|||||||
updateMany: updateEnvelopeRecipientsRoute,
|
updateMany: updateEnvelopeRecipientsRoute,
|
||||||
delete: deleteEnvelopeRecipientRoute,
|
delete: deleteEnvelopeRecipientRoute,
|
||||||
set: setEnvelopeRecipientsRoute,
|
set: setEnvelopeRecipientsRoute,
|
||||||
|
report: reportRecipientRoute,
|
||||||
},
|
},
|
||||||
field: {
|
field: {
|
||||||
get: getEnvelopeFieldRoute,
|
get: getEnvelopeFieldRoute,
|
||||||
|
|||||||
Reference in New Issue
Block a user