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:
@@ -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 = {
|
||||
isDocument?: boolean;
|
||||
reportUrl?: string;
|
||||
};
|
||||
|
||||
export const TemplateFooter = ({ isDocument = true }: TemplateFooterProps) => {
|
||||
export const TemplateFooter = ({ isDocument = true, reportUrl }: TemplateFooterProps) => {
|
||||
const branding = useBranding();
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<Text className="my-4 text-base text-slate-400">
|
||||
<Trans>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { TemplateFooter } from '../template-components/template-footer';
|
||||
|
||||
export type DocumentCompletedEmailTemplateProps = Partial<TemplateDocumentCompletedProps> & {
|
||||
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 = ({
|
||||
</Container>
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter />
|
||||
<TemplateFooter reportUrl={reportUrl} />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
|
||||
@@ -20,6 +20,7 @@ export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInvitePro
|
||||
teamEmail?: string;
|
||||
includeSenderDetails?: boolean;
|
||||
organisationType?: OrganisationType;
|
||||
reportUrl?: string;
|
||||
};
|
||||
|
||||
export const DocumentInviteEmailTemplate = ({
|
||||
@@ -34,6 +35,7 @@ export const DocumentInviteEmailTemplate = ({
|
||||
teamName = '',
|
||||
includeSenderDetails,
|
||||
organisationType,
|
||||
reportUrl,
|
||||
}: DocumentInviteEmailTemplateProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
@@ -114,7 +116,7 @@ export const DocumentInviteEmailTemplate = ({
|
||||
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter />
|
||||
<TemplateFooter reportUrl={reportUrl} />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
|
||||
@@ -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 = ({
|
||||
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter />
|
||||
<TemplateFooter reportUrl={reportUrl} />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
emailCount Int @default(0)
|
||||
apiCount Int @default(0)
|
||||
emailReports Int @default(0)
|
||||
|
||||
@@unique([organisationId, period])
|
||||
@@index([organisationId])
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user