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
@@ -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),
}));
@@ -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(),
@@ -53,6 +53,7 @@ export const ZGetAdminOrganisationResponseSchema = ZOrganisationSchema.extend({
documentCount: true,
emailCount: 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 { 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,