From ada406f8422cd463842aca23042e35972ef4a3eb Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Sat, 11 Jul 2026 03:17:40 +0000 Subject: [PATCH] fix: render error messages for invalid templates --- .../direct-template-invalid-page.tsx | 23 ++++ .../envelope-editor-fields-page.tsx | 3 + ...e-editor-invalid-direct-template-alert.tsx | 55 ++++++++ .../envelope-editor-preview-page.tsx | 3 + .../routes/_recipient+/d.$token+/_index.tsx | 28 ++++ apps/remix/app/utils/toast-error-messages.ts | 8 ++ ...nvelope-direct-template-validation.spec.ts | 76 +++++++++++ .../public-profiles/public-profiles.spec.ts | 12 ++ .../e2e/templates/direct-templates.spec.ts | 125 +++++++++++++++--- .../e2e/templates/template-use-dialog.spec.ts | 101 ++++++++++++++ packages/lib/errors/app-error.ts | 9 ++ .../lib/server-only/document/send-document.ts | 2 +- ...et-envelope-for-direct-template-signing.ts | 12 ++ .../create-document-from-direct-template.ts | 12 ++ packages/prisma/seed/templates.ts | 32 +++++ 15 files changed, 480 insertions(+), 21 deletions(-) create mode 100644 apps/remix/app/components/general/direct-template/direct-template-invalid-page.tsx create mode 100644 apps/remix/app/components/general/envelope-editor/envelope-editor-invalid-direct-template-alert.tsx create mode 100644 packages/app-tests/e2e/envelope-editor-v2/envelope-direct-template-validation.spec.ts create mode 100644 packages/app-tests/e2e/templates/template-use-dialog.spec.ts diff --git a/apps/remix/app/components/general/direct-template/direct-template-invalid-page.tsx b/apps/remix/app/components/general/direct-template/direct-template-invalid-page.tsx new file mode 100644 index 000000000..c95b4109f --- /dev/null +++ b/apps/remix/app/components/general/direct-template/direct-template-invalid-page.tsx @@ -0,0 +1,23 @@ +import { Trans } from '@lingui/react/macro'; +import { AlertTriangleIcon } from 'lucide-react'; + +export const DirectTemplateInvalidPageView = () => { + return ( +
+
+ + +

+ Invalid direct link template +

+ +

+ + This direct link template cannot be used because one or more signers do not have a signature field assigned. + Please contact the sender to update the template. + +

+
+
+ ); +}; diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx index 945d3c308..a6146156a 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx @@ -54,6 +54,7 @@ import { useCurrentTeam } from '~/providers/team'; import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop'; import { EnvelopeEditorFieldsPageRenderer } from './envelope-editor-fields-page-renderer'; +import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert'; import { EnvelopeRendererFileSelector } from './envelope-file-selector'; import { EnvelopeRecipientSelector } from './envelope-recipient-selector'; @@ -238,6 +239,8 @@ export const EnvelopeEditorFieldsPage = () => { } /> + + {/* Document View */}
{envelope.recipients.length === 0 && ( diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-invalid-direct-template-alert.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-invalid-direct-template-alert.tsx new file mode 100644 index 000000000..650c7e5a5 --- /dev/null +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-invalid-direct-template-alert.tsx @@ -0,0 +1,55 @@ +import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; +import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients'; +import { cn } from '@documenso/ui/lib/utils'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Trans } from '@lingui/react/macro'; +import { useMemo } from 'react'; + +export type EnvelopeEditorInvalidDirectTemplateAlertProps = { + className?: string; +}; + +/** + * Warns that a direct link template cannot be used because one or more signers + * are missing a signature field. + */ +export const EnvelopeEditorInvalidDirectTemplateAlert = ({ + className, +}: EnvelopeEditorInvalidDirectTemplateAlertProps) => { + const { envelope, isTemplate } = useCurrentEnvelopeEditor(); + + const signersMissingSignatureFields = useMemo(() => { + if (!isTemplate || !envelope.directLink?.enabled) { + return []; + } + + return getRecipientsWithMissingFields(envelope.recipients, envelope.fields); + }, [isTemplate, envelope.directLink, envelope.recipients, envelope.fields]); + + if (signersMissingSignatureFields.length === 0) { + return null; + } + + return ( + + + Invalid direct link template + + + + + Recipients cannot use this direct link template because the following signers are missing a signature field + + +
    + {signersMissingSignatureFields.map((recipient, i) => ( +
  • {recipient.email || recipient.name || `Recipient ${i + 1}`}
  • + ))} +
+
+
+ ); +}; diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx index 42144b6f8..df5339360 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx @@ -22,6 +22,7 @@ import { match } from 'ts-pattern'; import { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer'; import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer'; +import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert'; import { EnvelopeRendererFileSelector } from './envelope-file-selector'; export const EnvelopeEditorPreviewPage = () => { @@ -228,6 +229,8 @@ export const EnvelopeEditorPreviewPage = () => { {/* Horizontal envelope item selector */} + + Preview Mode diff --git a/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx b/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx index bd7b34383..dff005fe1 100644 --- a/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx +++ b/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx @@ -7,6 +7,7 @@ import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/ import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token'; import { DocumentAccessAuth } from '@documenso/lib/types/document-auth'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; +import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients'; import { prisma } from '@documenso/prisma'; import { Plural } from '@lingui/react/macro'; import { UsersIcon } from 'lucide-react'; @@ -14,6 +15,7 @@ import { redirect } from 'react-router'; import { match } from 'ts-pattern'; import { Header as AuthenticatedHeader } from '~/components/general/app-header'; +import { DirectTemplateInvalidPageView } from '~/components/general/direct-template/direct-template-invalid-page'; import { DirectTemplatePageView } from '~/components/general/direct-template/direct-template-page'; import { DirectTemplateAuthPageView } from '~/components/general/direct-template/direct-template-signing-auth-page'; import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page'; @@ -70,8 +72,18 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => { }; } + const recipientsWithMissingFields = getRecipientsWithMissingFields(template.recipients, template.fields); + + if (recipientsWithMissingFields.length > 0) { + return { + isAccessAuthValid: true, + isTemplateMissingSignatures: true, + } as const; + } + return { isAccessAuthValid: true, + isTemplateMissingSignatures: false, template: { ...template, folder: null, @@ -96,6 +108,7 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => { .then((envelopeForSigning) => { return { isDocumentAccessValid: true, + isTemplateMissingSignatures: false, envelopeForSigning, } as const; }) @@ -108,6 +121,13 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => { } as const; } + if (error.code === AppErrorCode.MISSING_SIGNATURE_FIELD) { + return { + isDocumentAccessValid: true, + isTemplateMissingSignatures: true, + } as const; + } + throw new Response('Not Found', { status: 404 }); }); }; @@ -181,6 +201,10 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited; } + if (data.isTemplateMissingSignatures) { + return ; + } + const { template, directTemplateRecipient } = data; return ( @@ -235,6 +259,10 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited; } + if (data.isTemplateMissingSignatures) { + return ; + } + const { envelope, recipient } = data.envelopeForSigning; const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({ diff --git a/apps/remix/app/utils/toast-error-messages.ts b/apps/remix/app/utils/toast-error-messages.ts index 0f2ce3031..462f42694 100644 --- a/apps/remix/app/utils/toast-error-messages.ts +++ b/apps/remix/app/utils/toast-error-messages.ts @@ -32,6 +32,10 @@ export const getDirectTemplateErrorMessage = (code: string): ToastMessageDescrip return match(code) .with('RECIPIENT_LIMIT_EXCEEDED', () => RECIPIENT_LIMIT_EXCEEDED_ERROR_MESSAGE) .with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE) + .with(AppErrorCode.MISSING_SIGNATURE_FIELD, () => ({ + title: msg`Missing signature fields`, + description: msg`This direct link template cannot be used because one or more signers do not have a signature field assigned.`, + })) .otherwise(() => ({ title: msg`Something went wrong`, description: msg`We were unable to submit this document at this time. Please try again later.`, @@ -77,6 +81,10 @@ export const getTemplateUseErrorMessage = (code: string): ToastMessageDescriptor title: msg`Error`, description: msg`The document was created but could not be sent to recipients.`, })) + .with(AppErrorCode.MISSING_SIGNATURE_FIELD, () => ({ + title: msg`Missing signature fields`, + description: msg`The document could not be sent because some signers do not have a signature field. Please edit the template and add a signature field for each signer.`, + })) .with(AppErrorCode.INVALID_BODY, AppErrorCode.INVALID_REQUEST, () => ({ title: msg`Error`, description: msg`The document could not be created because of missing or invalid information. Please review the template's recipients and fields.`, diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-direct-template-validation.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-direct-template-validation.spec.ts new file mode 100644 index 000000000..36af4cad2 --- /dev/null +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-direct-template-validation.spec.ts @@ -0,0 +1,76 @@ +import { seedDirectTemplate } from '@documenso/prisma/seed/templates'; +import { seedUser } from '@documenso/prisma/seed/users'; +import { expect, type Page, test } from '@playwright/test'; + +import { apiSignin } from '../fixtures/authentication'; +import { clickEnvelopeEditorStep } from '../fixtures/envelope-editor'; + +const INVALID_DIRECT_TEMPLATE_ALERT_TITLE = 'Invalid direct link template'; + +/** + * Place a field on the PDF canvas in the envelope editor. + */ +const placeFieldOnPdf = async (root: Page, fieldName: 'Signature' | 'Text', position: { x: number; y: number }) => { + await root.getByRole('button', { name: fieldName, exact: true }).click(); + + const canvas = root.locator('.konva-container canvas').first(); + await expect(canvas).toBeVisible(); + await canvas.click({ position }); +}; + +/** + * Seed a V2 direct template and open it in the native template editor. + * + * Only the native template editor is covered here: direct links only exist + * for templates and are not part of the embedded editor surfaces. + */ +const openDirectTemplateEditor = async (page: Page, options: { createDirectRecipientSignatureField: boolean }) => { + const { user, team } = await seedUser(); + + const template = await seedDirectTemplate({ + title: `E2E Direct Template Validation ${Date.now()}`, + userId: user.id, + teamId: team.id, + internalVersion: 2, + createDirectRecipientSignatureField: options.createDirectRecipientSignatureField, + }); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/templates/${template.id}/edit`, + }); + + return { user, team, template }; +}; + +test.describe('template editor', () => { + test('shows invalid direct template warning when a signer has no signature field', async ({ page }) => { + await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: false }); + + await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).toBeVisible(); + await expect(page.getByText('are missing a signature field')).toBeVisible(); + }); + + test('does not show the warning when all signers have signature fields', async ({ page }) => { + await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: true }); + + // Wait for the editor to render before asserting the banner is absent. + await expect(page.getByTestId('envelope-editor-step-upload')).toBeVisible(); + await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).not.toBeVisible(); + }); + + test('warning disappears after placing a signature field', async ({ page }) => { + await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: false }); + + await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).toBeVisible(); + + // Place a signature field for the direct recipient (auto-selected single recipient). + await clickEnvelopeEditorStep(page, 'addFields'); + await expect(page.locator('.konva-container canvas').first()).toBeVisible(); + await placeFieldOnPdf(page, 'Signature', { x: 120, y: 140 }); + + // The banner clears once the field is autosaved and the envelope state updates. + await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).not.toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/packages/app-tests/e2e/public-profiles/public-profiles.spec.ts b/packages/app-tests/e2e/public-profiles/public-profiles.spec.ts index 414c18508..6f6ce998c 100644 --- a/packages/app-tests/e2e/public-profiles/public-profiles.spec.ts +++ b/packages/app-tests/e2e/public-profiles/public-profiles.spec.ts @@ -6,6 +6,7 @@ import { expect, test } from '@playwright/test'; import { apiSignin } from '../fixtures/authentication'; import { expectToastTextToBeVisible } from '../fixtures/generic'; +import { signSignaturePad } from '../fixtures/signature'; test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => { const { user, team } = await seedUser(); @@ -73,8 +74,19 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => { await expect(page.locator('body')).toContainText('public-direct-template-title'); await expect(page.locator('body')).toContainText('public-direct-template-description'); + const directSignatureField = directTemplate.fields[0]; + + if (!directSignatureField) { + throw new Error('Expected seeded direct template signature field to exist'); + } + await page.getByRole('link', { name: 'Sign' }).click(); await page.getByRole('button', { name: 'Continue' }).click(); + + await signSignaturePad(page); + await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click(); + await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true'); + await page.getByRole('button', { name: 'Complete' }).click(); await page.getByRole('button', { name: 'Sign' }).click(); diff --git a/packages/app-tests/e2e/templates/direct-templates.spec.ts b/packages/app-tests/e2e/templates/direct-templates.spec.ts index 69a4c9ad2..fbfa218c5 100644 --- a/packages/app-tests/e2e/templates/direct-templates.spec.ts +++ b/packages/app-tests/e2e/templates/direct-templates.spec.ts @@ -197,7 +197,18 @@ test('[DIRECT_TEMPLATES]: V1 direct template link auth access', async ({ page }) await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); await expect(page.getByLabel('Email')).toBeDisabled(); + const directSignatureField = directTemplateWithAuth.fields[0]; + + if (!directSignatureField) { + throw new Error('Expected seeded direct template signature field to exist'); + } + await page.getByRole('button', { name: 'Continue' }).click(); + + await signSignaturePad(page); + await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click(); + await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true'); + await page.getByRole('button', { name: 'Complete' }).click(); await page.getByRole('button', { name: 'Sign' }).click(); @@ -235,6 +246,37 @@ test('[DIRECT_TEMPLATES]: V2 direct template link auth access', async ({ page }) await page.goto(directTemplatePath); await expect(page.getByRole('heading', { name: 'Personal direct template link' })).toBeVisible(); + + const directSignatureField = directTemplateWithAuth.fields[0]; + + if (!directSignatureField) { + throw new Error('Expected seeded direct template signature field to exist'); + } + + // Wait for the PDF and the Konva canvas overlay to be ready. + await expect(page.locator('img[data-page-number]').first()).toBeVisible({ timeout: 30_000 }); + const canvas = page.locator('.konva-container canvas').first(); + await expect(canvas).toBeVisible({ timeout: 30_000 }); + + // Sign the direct template recipient's signature field via the canvas-based V2 UI. + await signSignaturePad(page); + + const canvasBox = await canvas.boundingBox(); + + if (!canvasBox) { + throw new Error('Canvas bounding box not found'); + } + + const x = + (Number(directSignatureField.positionX) / 100) * canvasBox.width + + ((Number(directSignatureField.width) / 100) * canvasBox.width) / 2; + const y = + (Number(directSignatureField.positionY) / 100) * canvasBox.height + + ((Number(directSignatureField.height) / 100) * canvasBox.height) / 2; + + await canvas.click({ position: { x, y } }); + await expect(page.getByText('0 Fields Remaining').first()).toBeVisible({ timeout: 10_000 }); + await page.getByRole('button', { name: 'Complete' }).click(); await expect(page.getByLabel('Your Email')).not.toBeVisible(); @@ -266,6 +308,16 @@ test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ p await expect(page.getByText('Next Recipient Name')).not.toBeVisible(); + const directSignatureField = template.fields[0]; + + if (!directSignatureField) { + throw new Error('Expected seeded direct template signature field to exist'); + } + + await signSignaturePad(page); + await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click(); + await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true'); + await page.getByRole('button', { name: 'Complete' }).click(); await page.getByRole('button', { name: 'Sign' }).click(); await page.waitForURL(/\/sign/); @@ -299,19 +351,13 @@ test('[DIRECT_TEMPLATES]: V1 use direct template link with 2 recipients with nex }, }); - const directTemplateRecipient = template.recipients[0]; + // The seeded direct template already includes a signature field for the direct recipient. + const directSignatureField = template.fields[0]; - if (!directTemplateRecipient) { - throw new Error('Expected direct template recipient to exist'); + if (!directSignatureField) { + throw new Error('Expected seeded direct template signature field to exist'); } - // All SIGNER recipients need a signature field for sendDocument to dispatch emails. - const directSignatureField = await seedSignatureFieldForRecipient({ - envelopeId: template.id, - recipientId: directTemplateRecipient.id, - positionY: 10, - }); - const originalName = 'Signer 2'; const originalSecondSignerEmail = seedTestEmail(); @@ -413,19 +459,13 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex }, }); - const directTemplateRecipient = template.recipients[0]; + // The seeded direct template already includes a signature field for the direct recipient. + const directSignatureField = template.fields[0]; - if (!directTemplateRecipient) { - throw new Error('Expected direct template recipient to exist'); + if (!directSignatureField) { + throw new Error('Expected seeded direct template signature field to exist'); } - // All SIGNER recipients need a signature field for sendDocument to dispatch emails. - const directSignatureField = await seedSignatureFieldForRecipient({ - envelopeId: template.id, - recipientId: directTemplateRecipient.id, - positionY: 10, - }); - const originalName = 'Signer 2'; const originalSecondSignerEmail = seedTestEmail(); @@ -521,3 +561,48 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex expect(updatedSecondRecipient.email).toBe(newSecondSignerEmail); await expectSigningRequestJobForRecipient(updatedSecondRecipient.id); }); + +test('[DIRECT_TEMPLATES]: V1 direct template without signature fields shows invalid template page', async ({ + page, +}) => { + const { user, team } = await seedUser(); + + const template = await seedDirectTemplate({ + title: 'V1 invalid direct template', + userId: user.id, + teamId: team.id, + createDirectRecipientSignatureField: false, + }); + + await page.goto(formatDirectTemplatePath(template.directLink?.token || '')); + + await expect(page.getByRole('heading', { name: 'Invalid direct link template' })).toBeVisible(); + await expect(page.getByText('This direct link template cannot be used because one or more signers')).toBeVisible(); + + // The signing flow must not render. + await expect(page.getByRole('heading', { name: 'General' })).not.toBeVisible(); + await expect(page.getByRole('button', { name: 'Continue' })).not.toBeVisible(); +}); + +test('[DIRECT_TEMPLATES]: V2 direct template without signature fields shows invalid template page', async ({ + page, +}) => { + const { user, team } = await seedUser(); + + const template = await seedDirectTemplate({ + title: 'V2 invalid direct template', + userId: user.id, + teamId: team.id, + internalVersion: 2, + createDirectRecipientSignatureField: false, + }); + + await page.goto(formatDirectTemplatePath(template.directLink?.token || '')); + + await expect(page.getByRole('heading', { name: 'Invalid direct link template' })).toBeVisible(); + await expect(page.getByText('This direct link template cannot be used because one or more signers')).toBeVisible(); + + // The signing flow (PDF canvas) must not render. + await expect(page.locator('.konva-container canvas')).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Complete' })).not.toBeVisible(); +}); diff --git a/packages/app-tests/e2e/templates/template-use-dialog.spec.ts b/packages/app-tests/e2e/templates/template-use-dialog.spec.ts new file mode 100644 index 000000000..1dbca5f8d --- /dev/null +++ b/packages/app-tests/e2e/templates/template-use-dialog.spec.ts @@ -0,0 +1,101 @@ +import { FIELD_SIGNATURE_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta'; +import { prisma } from '@documenso/prisma'; +import { seedTemplate } from '@documenso/prisma/seed/templates'; +import { seedUser } from '@documenso/prisma/seed/users'; +import { expect, test } from '@playwright/test'; +import { DocumentStatus, FieldType } from '@prisma/client'; + +import { apiSignin } from '../fixtures/authentication'; +import { expectToastTextToBeVisible } from '../fixtures/generic'; + +const seedSignatureFieldForRecipient = async (options: { envelopeId: string; recipientId: number }) => { + const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({ + where: { envelopeId: options.envelopeId }, + }); + + return await prisma.field.create({ + data: { + envelopeId: options.envelopeId, + envelopeItemId: envelopeItem.id, + recipientId: options.recipientId, + type: FieldType.SIGNATURE, + page: 1, + positionX: 5, + positionY: 10, + width: 20, + height: 5, + customText: '', + inserted: false, + fieldMeta: FIELD_SIGNATURE_META_DEFAULT_VALUES, + }, + }); +}; + +test('[TEMPLATE_USE]: shows missing signature fields error when sending a template without signature fields', async ({ + page, +}) => { + const { user, team } = await seedUser(); + + // seedTemplate creates one SIGNER recipient and no fields. + await seedTemplate({ + title: 'Template missing signature fields', + userId: user.id, + teamId: team.id, + }); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/templates`, + }); + + await page.getByRole('button', { name: 'Use Template' }).click(); + await expect(page.getByRole('heading', { name: 'Create document from template' })).toBeVisible(); + + // Enable distribution so the document is sent on creation. + await page.locator('#distributeDocument').click(); + await page.getByRole('button', { name: 'Create and send' }).click(); + + await expectToastTextToBeVisible(page, 'Missing signature fields'); + await expectToastTextToBeVisible( + page, + 'The document could not be sent because some signers do not have a signature field', + ); +}); + +test('[TEMPLATE_USE]: creates and sends a document when signers have signature fields', async ({ page }) => { + const { user, team } = await seedUser(); + + const template = await seedTemplate({ + title: 'Template with signature fields', + userId: user.id, + teamId: team.id, + }); + + await seedSignatureFieldForRecipient({ + envelopeId: template.id, + recipientId: template.recipients[0].id, + }); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/templates`, + }); + + await page.getByRole('button', { name: 'Use Template' }).click(); + await expect(page.getByRole('heading', { name: 'Create document from template' })).toBeVisible(); + + await page.locator('#distributeDocument').click(); + await page.getByRole('button', { name: 'Create and send' }).click(); + + await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`)); + + const envelopeId = page.url().split('/').pop()?.split('?')[0]; + + const envelope = await prisma.envelope.findFirstOrThrow({ + where: { id: envelopeId }, + }); + + expect(envelope.status).toBe(DocumentStatus.PENDING); +}); diff --git a/packages/lib/errors/app-error.ts b/packages/lib/errors/app-error.ts index 75338e0de..5dc3e7b7e 100644 --- a/packages/lib/errors/app-error.ts +++ b/packages/lib/errors/app-error.ts @@ -36,6 +36,13 @@ export enum AppErrorCode { */ ENVELOPE_TSP_LOCKED = 'ENVELOPE_TSP_LOCKED', + /** + * A signer recipient does not have a signature field assigned. Thrown when + * distributing an envelope or using a direct template where at least one + * signer has no signature field. + */ + MISSING_SIGNATURE_FIELD = 'MISSING_SIGNATURE_FIELD', + /** * CSC (Cloud Signature Consortium) error codes. See the CSC QES V1 spec * for the recovery taxonomy. @@ -84,6 +91,7 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record (r.name ? `${r.name} (${r.email}, id: ${r.id})` : `${r.email} (id: ${r.id})`)) .join(', '); - throw new AppError(AppErrorCode.INVALID_REQUEST, { + throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, { message: `The following recipients are missing required fields: ${missingRecipientDescriptions}. Signers must have at least one signature field.`, }); } diff --git a/packages/lib/server-only/envelope/get-envelope-for-direct-template-signing.ts b/packages/lib/server-only/envelope/get-envelope-for-direct-template-signing.ts index e855f4fbe..6383bc7f7 100644 --- a/packages/lib/server-only/envelope/get-envelope-for-direct-template-signing.ts +++ b/packages/lib/server-only/envelope/get-envelope-for-direct-template-signing.ts @@ -5,6 +5,7 @@ import { match } from 'ts-pattern'; import { AppError, AppErrorCode } from '../../errors/app-error'; import { DocumentAccessAuth, type TDocumentAuthMethods } from '../../types/document-auth'; import { extractDocumentAuthMethods } from '../../utils/document-auth'; +import { getRecipientsWithMissingFields } from '../../utils/recipients'; import { extractFieldAutoInsertValues } from '../document/send-document'; import { getTeamSettings } from '../team/get-team-settings'; import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing'; @@ -125,6 +126,17 @@ export const getEnvelopeForDirectTemplateSigning = async ({ }); } + const recipientsWithMissingFields = getRecipientsWithMissingFields( + envelope.recipients, + envelope.recipients.flatMap((envelopeRecipient) => envelopeRecipient.fields), + ); + + if (recipientsWithMissingFields.length > 0) { + throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, { + message: 'One or more signers on this direct template are missing a signature field', + }); + } + const settings = await getTeamSettings({ teamId: envelope.teamId }); const sender = settings.includeSenderDetails diff --git a/packages/lib/server-only/template/create-document-from-direct-template.ts b/packages/lib/server-only/template/create-document-from-direct-template.ts index f7682d707..e767170b7 100644 --- a/packages/lib/server-only/template/create-document-from-direct-template.ts +++ b/packages/lib/server-only/template/create-document-from-direct-template.ts @@ -40,6 +40,7 @@ import { extractDocumentAuthMethods, } from '../../utils/document-auth'; import { mapSecondaryIdToTemplateId } from '../../utils/envelope'; +import { getRecipientsWithMissingFields } from '../../utils/recipients'; import { sendDocument } from '../document/send-document'; import { validateFieldAuth } from '../document/validate-field-auth'; import { incrementDocumentId } from '../envelope/increment-id'; @@ -172,6 +173,17 @@ export const createDocumentFromDirectTemplate = async ({ }); } + const recipientsWithMissingFields = getRecipientsWithMissingFields( + recipients, + recipients.flatMap((recipient) => recipient.fields), + ); + + if (recipientsWithMissingFields.length > 0) { + throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, { + message: 'One or more signers on this direct template are missing a signature field', + }); + } + if (directTemplateEnvelope.updatedAt.getTime() !== templateUpdatedAt.getTime()) { throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Template no longer matches' }); } diff --git a/packages/prisma/seed/templates.ts b/packages/prisma/seed/templates.ts index b72e0a39c..94c8bf355 100644 --- a/packages/prisma/seed/templates.ts +++ b/packages/prisma/seed/templates.ts @@ -6,6 +6,7 @@ import { DIRECT_TEMPLATE_RECIPIENT_NAME, } from '@documenso/lib/constants/direct-templates'; import { incrementTemplateId } from '@documenso/lib/server-only/envelope/increment-id'; +import { FIELD_SIGNATURE_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta'; import { SignatureLevel } from '@documenso/lib/types/signature-level'; import { prefixedId } from '@documenso/lib/universal/id'; @@ -15,6 +16,7 @@ import { DocumentDataType, DocumentSource, EnvelopeType, + FieldType, ReadStatus, RecipientRole, SendStatus, @@ -29,6 +31,11 @@ type SeedTemplateOptions = { teamId: number; internalVersion?: 1 | 2; createTemplateOptions?: Partial; + /** + * Only used by seedDirectTemplate. Creates a signature field for the direct + * recipient so the seeded direct template is valid. Defaults to true. + */ + createDirectRecipientSignatureField?: boolean; }; type CreateTemplateOptions = { @@ -215,6 +222,31 @@ export const seedDirectTemplate = async (options: SeedTemplateOptions) => { }, }); + const { createDirectRecipientSignatureField = true } = options; + + if (createDirectRecipientSignatureField) { + const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({ + where: { envelopeId: template.id }, + }); + + await prisma.field.create({ + data: { + envelopeId: template.id, + envelopeItemId: envelopeItem.id, + recipientId: directTemplateRecpient.id, + type: FieldType.SIGNATURE, + page: 1, + positionX: 5, + positionY: 10, + width: 20, + height: 5, + customText: '', + inserted: false, + fieldMeta: FIELD_SIGNATURE_META_DEFAULT_VALUES, + }, + }); + } + return await prisma.envelope.findFirstOrThrow({ where: { id: template.id,