diff --git a/apps/remix/app/components/general/organisation-usage-panel.tsx b/apps/remix/app/components/general/organisation-usage-panel.tsx index 7936e539f..c9ed06265 100644 --- a/apps/remix/app/components/general/organisation-usage-panel.tsx +++ b/apps/remix/app/components/general/organisation-usage-panel.tsx @@ -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[]; + monthlyStats: Pick< + OrganisationMonthlyStat, + 'period' | 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports' + >[]; organisationClaim: OrganisationClaim; }; @@ -16,34 +22,56 @@ export const OrganisationUsagePanel = ({ monthlyStats, organisationClaim, }: OrganisationUsagePanelProps) => { + const [selectedPeriod, setSelectedPeriod] = useState(() => 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: Documents, - used: monthlyStats[0]?.documentCount ?? 0, + used: selectedStat?.documentCount ?? 0, effectiveLimit: organisationClaim.documentQuota, }, { counter: 'email' as const, label: Emails, - used: monthlyStats[0]?.emailCount ?? 0, + used: selectedStat?.emailCount ?? 0, effectiveLimit: organisationClaim.emailQuota, }, { counter: 'api' as const, label: API requests, - 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 (
-
+

- Usage for period: {monthlyStats[0]?.period || 'N/A'} + Usage for period: {selectedStat?.period || 'N/A'}

+ + {monthlyStats.length > 0 && ( + + )}
{rows.map((row) => { @@ -67,7 +95,7 @@ export const OrganisationUsagePanel = ({ {row.effectiveLimit && row.effectiveLimit > 0 ? : null} - {monthlyStats[0] && ( + {selectedStat && isCurrentPeriod && (
@@ -75,6 +103,15 @@ export const OrganisationUsagePanel = ({
); })} + +
+
+ + Reports + + {selectedStat?.emailReports ?? 0} +
+
); }; diff --git a/apps/remix/app/components/tables/admin-organisation-stats-table.tsx b/apps/remix/app/components/tables/admin-organisation-stats-table.tsx index 0e8bf7003..88c7c60b3 100644 --- a/apps/remix/app/components/tables/admin-organisation-stats-table.tsx +++ b/apps/remix/app/components/tables/admin-organisation-stats-table.tsx @@ -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 ~{formatPerDay(used)}/day; + } + + return ( + + {used} / {quota === null ? '∞' : quota} + + ); + }; + const sortableHeader = (label: string, column: OrderByColumn) => ( + + ); +} diff --git a/packages/app-tests/e2e/recipient/report-sender.spec.ts b/packages/app-tests/e2e/recipient/report-sender.spec.ts new file mode 100644 index 000000000..4bca25939 --- /dev/null +++ b/packages/app-tests/e2e/recipient/report-sender.spec.ts @@ -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); +}); diff --git a/packages/email/template-components/template-footer.tsx b/packages/email/template-components/template-footer.tsx index 716bd3dc7..e7e72b11b 100644 --- a/packages/email/template-components/template-footer.tsx +++ b/packages/email/template-components/template-footer.tsx @@ -5,13 +5,26 @@ import { useBranding } from '../providers/branding'; export type TemplateFooterProps = { isDocument?: boolean; + reportUrl?: string; }; -export const TemplateFooter = ({ isDocument = true }: TemplateFooterProps) => { +export const TemplateFooter = ({ isDocument = true, reportUrl }: TemplateFooterProps) => { const branding = useBranding(); return (
+ {reportUrl && ( + + + Did not expect this email?{' '} + + Click here to report the sender + + . Never sign a document you don't recognize or weren't expecting. + + + )} + {isDocument && !branding.brandingHidePoweredBy && ( diff --git a/packages/email/templates/document-completed.tsx b/packages/email/templates/document-completed.tsx index e8e616e38..3972cb10a 100644 --- a/packages/email/templates/document-completed.tsx +++ b/packages/email/templates/document-completed.tsx @@ -9,6 +9,7 @@ import { TemplateFooter } from '../template-components/template-footer'; export type DocumentCompletedEmailTemplateProps = Partial & { customBody?: string; + reportUrl?: string; }; export const DocumentCompletedEmailTemplate = ({ @@ -16,6 +17,7 @@ export const DocumentCompletedEmailTemplate = ({ documentName = 'Open Source Pledge.pdf', assetBaseUrl = 'http://localhost:3002', customBody, + reportUrl, }: DocumentCompletedEmailTemplateProps) => { const { _ } = useLingui(); const branding = useBranding(); @@ -51,7 +53,7 @@ export const DocumentCompletedEmailTemplate = ({ - +
diff --git a/packages/email/templates/document-invite.tsx b/packages/email/templates/document-invite.tsx index d57bb1bfa..db6f9e3fc 100644 --- a/packages/email/templates/document-invite.tsx +++ b/packages/email/templates/document-invite.tsx @@ -20,6 +20,7 @@ export type DocumentInviteEmailTemplateProps = Partial { const { _ } = useLingui(); const branding = useBranding(); @@ -114,7 +116,7 @@ export const DocumentInviteEmailTemplate = ({
- + diff --git a/packages/email/templates/document-reminder.tsx b/packages/email/templates/document-reminder.tsx index abbb571ae..76906d384 100644 --- a/packages/email/templates/document-reminder.tsx +++ b/packages/email/templates/document-reminder.tsx @@ -16,6 +16,7 @@ export type DocumentReminderEmailTemplateProps = { assetBaseUrl?: string; customBody?: string; role: RecipientRole; + reportUrl?: string; }; export const DocumentReminderEmailTemplate = ({ @@ -25,6 +26,7 @@ export const DocumentReminderEmailTemplate = ({ assetBaseUrl = 'http://localhost:3002', customBody, role = RecipientRole.SIGNER, + reportUrl, }: DocumentReminderEmailTemplateProps) => { const { _ } = useLingui(); const branding = useBranding(); @@ -75,7 +77,7 @@ export const DocumentReminderEmailTemplate = ({
- + diff --git a/packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts b/packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts index 538b4e9d3..b0e5624ba 100644 --- a/packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts @@ -211,6 +211,8 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCompletedEmai }; 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, { documentName: envelope.title, @@ -220,6 +222,7 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCompletedEmai isDirectTemplate && envelope.documentMeta?.message ? renderCustomEmailTemplate(envelope.documentMeta.message, customEmailTemplate) : undefined, + reportUrl, }); const [html, text] = await Promise.all([ diff --git a/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts b/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts index dd009d14f..9c057cc16 100644 --- a/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts @@ -165,6 +165,7 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`; + const reportUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/report/${recipient.token}`; const template = createElement(DocumentInviteEmailTemplate, { documentName: envelope.title, @@ -180,6 +181,7 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini teamName: team?.name, teamEmail: team?.teamEmail?.email, includeSenderDetails: settings.includeSenderDetails, + reportUrl, }); if (isRecipientEmailValidForSending(recipient)) { diff --git a/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts b/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts index ef6d52662..5f9ef9e11 100644 --- a/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts +++ b/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts @@ -154,6 +154,7 @@ export const run = async ({ payload, io }: { payload: TProcessSigningReminderJob const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; 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 // 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, customBody: emailMessage, role: recipient.role, + reportUrl, }); const [html, text] = await Promise.all([ diff --git a/packages/lib/server-only/admin/admin-find-documents.ts b/packages/lib/server-only/admin/admin-find-documents.ts index 71387ad30..994422ba7 100644 --- a/packages/lib/server-only/admin/admin-find-documents.ts +++ b/packages/lib/server-only/admin/admin-find-documents.ts @@ -1,5 +1,6 @@ import { prisma } from '@documenso/prisma'; import { EnvelopeType, type Prisma } from '@prisma/client'; +import { z } from 'zod'; import type { FindResultResponse } from '../../types/search-params'; @@ -9,6 +10,16 @@ export interface AdminFindDocumentsOptions { 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) => { let termFilters: Prisma.EnvelopeWhereInput | undefined = !query ? 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 = { id: { 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 = { secondaryId: { equals: query, diff --git a/packages/lib/server-only/document/resend-document.ts b/packages/lib/server-only/document/resend-document.ts index 1495989e0..21f4034d6 100644 --- a/packages/lib/server-only/document/resend-document.ts +++ b/packages/lib/server-only/document/resend-document.ts @@ -210,6 +210,7 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`; + const reportUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/report/${recipient.token}`; const template = createElement(DocumentInviteEmailTemplate, { documentName: envelope.title, @@ -225,6 +226,7 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe selfSigner, organisationType, teamName: envelope.team?.name, + reportUrl, }); const [html, text] = await Promise.all([ diff --git a/packages/lib/server-only/rate-limit/rate-limits.ts b/packages/lib/server-only/rate-limit/rate-limits.ts index a067546a4..a42c0f1a6 100644 --- a/packages/lib/server-only/rate-limit/rate-limits.ts +++ b/packages/lib/server-only/rate-limit/rate-limits.ts @@ -66,6 +66,12 @@ export const linkOrgAccountRateLimit = createRateLimit({ window: '1h', }); +export const reportSenderRateLimit = createRateLimit({ + action: 'recipient.report-sender', + max: 1, + window: '7d', +}); + // ---- API (Tier 4 - Standard) ---- export const apiV1RateLimit = createRateLimit({ diff --git a/packages/prisma/migrations/20260603015431_add_email_reports_stat/migration.sql b/packages/prisma/migrations/20260603015431_add_email_reports_stat/migration.sql new file mode 100644 index 000000000..6fd10b561 --- /dev/null +++ b/packages/prisma/migrations/20260603015431_add_email_reports_stat/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "OrganisationMonthlyStat" ADD COLUMN "emailReports" INTEGER NOT NULL DEFAULT 0; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 3fb188976..39e631d51 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -322,6 +322,7 @@ model OrganisationMonthlyStat { documentCount Int @default(0) emailCount Int @default(0) apiCount Int @default(0) + emailReports Int @default(0) @@unique([organisationId, period]) @@index([organisationId]) diff --git a/packages/trpc/server/admin-router/find-organisation-stats.ts b/packages/trpc/server/admin-router/find-organisation-stats.ts index ab6eebc27..98838ed1a 100644 --- a/packages/trpc/server/admin-router/find-organisation-stats.ts +++ b/packages/trpc/server/admin-router/find-organisation-stats.ts @@ -32,7 +32,7 @@ type FindOrganisationStatsOptions = { claimId?: string; page?: number; perPage?: number; - orderByColumn?: 'documentCount' | 'emailCount' | 'apiCount' | 'totalCount'; + orderByColumn?: 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports' | 'totalCount'; orderByDirection?: 'asc' | 'desc'; }; @@ -91,6 +91,10 @@ export const findOrganisationStats = async ({ 'OrganisationMonthlyStat.documentCount as documentCount', 'OrganisationMonthlyStat.emailCount as emailCount', 'OrganisationMonthlyStat.apiCount as apiCount', + 'OrganisationMonthlyStat.emailReports as emailReports', + 'OrganisationClaim.documentQuota as documentQuota', + 'OrganisationClaim.emailQuota as emailQuota', + 'OrganisationClaim.apiQuota as apiQuota', totalCountExpression.as('totalCount'), eb.fn.countAll().over().as('totalRows'), ]) @@ -99,6 +103,7 @@ export const findOrganisationStats = async ({ .with('documentCount', () => qb.orderBy('OrganisationMonthlyStat.documentCount', orderByDirection)) .with('emailCount', () => qb.orderBy('OrganisationMonthlyStat.emailCount', orderByDirection)) .with('apiCount', () => qb.orderBy('OrganisationMonthlyStat.apiCount', orderByDirection)) + .with('emailReports', () => qb.orderBy('OrganisationMonthlyStat.emailReports', orderByDirection)) .with('totalCount', () => qb.orderBy(totalCountExpression, orderByDirection)) .with(undefined, () => // Default ordering mirrors the desired SQL: email, api, document descending. @@ -126,6 +131,10 @@ export const findOrganisationStats = async ({ documentCount: Number(row.documentCount), emailCount: Number(row.emailCount), 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), })); diff --git a/packages/trpc/server/admin-router/find-organisation-stats.types.ts b/packages/trpc/server/admin-router/find-organisation-stats.types.ts index 6a89fad67..ef8d0e36d 100644 --- a/packages/trpc/server/admin-router/find-organisation-stats.types.ts +++ b/packages/trpc/server/admin-router/find-organisation-stats.types.ts @@ -9,7 +9,7 @@ export const ZFindOrganisationStatsRequestSchema = ZFindSearchParamsSchema.exten .optional(), claimId: z.string().describe('Filter stats by the original subscription claim ID.').optional(), orderByColumn: z - .enum(['documentCount', 'emailCount', 'apiCount', 'totalCount']) + .enum(['documentCount', 'emailCount', 'apiCount', 'emailReports', 'totalCount']) .describe('The column to sort by.') .optional(), orderByDirection: z.enum(['asc', 'desc']).describe('Sort direction.').default('desc'), @@ -26,6 +26,10 @@ export const ZFindOrganisationStatsResponseSchema = ZFindResultResponse.extend({ documentCount: z.number(), emailCount: z.number(), apiCount: z.number(), + emailReports: z.number(), + documentQuota: z.number().nullable(), + emailQuota: z.number().nullable(), + apiQuota: z.number().nullable(), totalCount: z.number(), }) .array(), diff --git a/packages/trpc/server/admin-router/get-admin-organisation.types.ts b/packages/trpc/server/admin-router/get-admin-organisation.types.ts index 667ee3811..765733c12 100644 --- a/packages/trpc/server/admin-router/get-admin-organisation.types.ts +++ b/packages/trpc/server/admin-router/get-admin-organisation.types.ts @@ -53,6 +53,7 @@ export const ZGetAdminOrganisationResponseSchema = ZOrganisationSchema.extend({ documentCount: true, emailCount: true, apiCount: true, + emailReports: true, }), ), }); diff --git a/packages/trpc/server/envelope-router/envelope-recipients/report-recipient.ts b/packages/trpc/server/envelope-router/envelope-recipients/report-recipient.ts new file mode 100644 index 000000000..dd67f79b9 --- /dev/null +++ b/packages/trpc/server/envelope-router/envelope-recipients/report-recipient.ts @@ -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}.`, + }); + }); diff --git a/packages/trpc/server/envelope-router/envelope-recipients/report-recipient.types.ts b/packages/trpc/server/envelope-router/envelope-recipients/report-recipient.types.ts new file mode 100644 index 000000000..07efc3129 --- /dev/null +++ b/packages/trpc/server/envelope-router/envelope-recipients/report-recipient.types.ts @@ -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; diff --git a/packages/trpc/server/envelope-router/router.ts b/packages/trpc/server/envelope-router/router.ts index 83b8281b8..845ba6df7 100644 --- a/packages/trpc/server/envelope-router/router.ts +++ b/packages/trpc/server/envelope-router/router.ts @@ -20,6 +20,7 @@ import { updateEnvelopeFieldsRoute } from './envelope-fields/update-envelope-fie import { createEnvelopeRecipientsRoute } from './envelope-recipients/create-envelope-recipients'; import { deleteEnvelopeRecipientRoute } from './envelope-recipients/delete-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 { findEnvelopeAuditLogsRoute } from './find-envelope-audit-logs'; import { findEnvelopesRoute } from './find-envelopes'; @@ -66,6 +67,7 @@ export const envelopeRouter = router({ updateMany: updateEnvelopeRecipientsRoute, delete: deleteEnvelopeRecipientRoute, set: setEnvelopeRecipientsRoute, + report: reportRecipientRoute, }, field: { get: getEnvelopeFieldRoute,