diff --git a/.agents/plans/clear-amber-light-validate-signer-fields-on-distribute.md b/.agents/plans/clear-amber-light-validate-signer-fields-on-distribute.md new file mode 100644 index 000000000..ec103c864 --- /dev/null +++ b/.agents/plans/clear-amber-light-validate-signer-fields-on-distribute.md @@ -0,0 +1,76 @@ +--- +date: 2026-01-26 +title: Validate Signer Fields On Distribute +--- + +## Summary + +Validate that signers have at least one signature field before allowing document/envelope distribution via API, matching the existing UI behavior. + +## Background + +The API originally allowed distributing documents/envelopes without validating that signers had signature fields assigned. This was intentional - we thought API users might have specific flows where this flexibility was needed. + +However, after running it this way for a while, we've observed that more often than not, API users inadvertently send documents without fields assigned. This causes confusion for their recipients (who receive a document with nothing to sign) and breaks their own systems expecting a completed signing flow. + +## Problem + +The API allowed distributing documents/envelopes even when signers had no signature fields assigned. This was inconsistent with the UI which validates this condition before allowing distribution. + +## Solution + +### 1. Create centralized validation helper + +**File**: `packages/lib/utils/recipients.ts` + +- Added `RECIPIENT_ROLES_THAT_REQUIRE_FIELDS` constant (currently only `SIGNER`) +- Added `getRecipientsWithMissingFields()` function that returns recipients missing required fields +- Uses existing `isSignatureFieldType` guard from `packages/prisma/guards/is-signature-field.ts` + +### 2. Add server-side validation + +**File**: `packages/lib/server-only/document/send-document.ts` + +- Added validation check that throws `AppError` with `INVALID_REQUEST` code when signers are missing signature fields +- This blocks both v1 and v2 API distribution endpoints since they both use `sendDocument()` + +### 3. Fix v1 API error handling + +**File**: `packages/api/v1/implementation.ts` + +- Changed `sendDocument` endpoint to use `AppError.toRestAPIError(err)` instead of always returning 500 +- Now returns 400 for validation errors + +### 4. Update UI to use shared helper + +**Files**: + +- `apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx` +- `packages/ui/primitives/document-flow/add-fields.tsx` + +### 5. Consolidate `hasSignatureField` checks + +Updated to use `isSignatureFieldType` guard (checks both `SIGNATURE` and `FREE_SIGNATURE`): + +- `apps/remix/app/components/general/document-signing/document-signing-form.tsx` +- `apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx` +- `apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx` +- `apps/remix/app/components/embed/embed-direct-template-client-page.tsx` +- `apps/remix/app/components/embed/embed-document-signing-page-v1.tsx` + +### 6. Add E2E tests + +**Files**: + +- `packages/app-tests/e2e/api/v1/document-sending.spec.ts` - 5 new tests +- `packages/app-tests/e2e/api/v2/distribute-validation.spec.ts` - 8 new tests + +## Test Coverage + +- Distribution fails when signer has no fields +- Distribution fails when signer has only non-signature fields +- Distribution succeeds with SIGNATURE field +- Distribution succeeds with FREE_SIGNATURE field (v1 only via Prisma) +- Distribution succeeds when VIEWER/CC/APPROVER have no fields +- Distribution fails when one of multiple signers is missing signature field +- Distribution succeeds when all signers have signature fields diff --git a/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx index 57454a7ab..ca244ade4 100644 --- a/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx @@ -3,13 +3,7 @@ import { useEffect, useMemo, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { useLingui } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro'; -import { - DocumentDistributionMethod, - DocumentStatus, - EnvelopeType, - FieldType, - RecipientRole, -} from '@prisma/client'; +import { DocumentDistributionMethod, DocumentStatus, EnvelopeType } from '@prisma/client'; import { AnimatePresence, motion } from 'framer-motion'; import { InfoIcon } from 'lucide-react'; import { useForm } from 'react-hook-form'; @@ -20,6 +14,7 @@ import * as z from 'zod'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; +import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients'; import { trpc, trpc as trpcReact } from '@documenso/trpc/react'; import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper'; import { cn } from '@documenso/ui/lib/utils'; @@ -140,14 +135,7 @@ export const EnvelopeDistributeDialog = ({ ); const recipientsMissingSignatureFields = useMemo( - () => - recipientsWithIndex.filter( - (recipient) => - recipient.role === RecipientRole.SIGNER && - !envelope.fields.some( - (field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id, - ), - ), + () => getRecipientsWithMissingFields(recipientsWithIndex, envelope.fields), [recipientsWithIndex, envelope.fields], ); diff --git a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx index 25fd330ba..1671c0dc6 100644 --- a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx +++ b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx @@ -3,8 +3,14 @@ import { useEffect, useLayoutEffect, useState } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { DocumentMeta, EnvelopeItem, Recipient, Signature } from '@prisma/client'; -import { type Field, FieldType } from '@prisma/client'; +import { + type DocumentMeta, + type EnvelopeItem, + type Field, + FieldType, + type Recipient, + type Signature, +} from '@prisma/client'; import { LucideChevronDown, LucideChevronUp } from 'lucide-react'; import { DateTime } from 'luxon'; import { useSearchParams } from 'react-router'; @@ -18,6 +24,7 @@ import { isRequiredField, } from '@documenso/lib/utils/advanced-fields-helpers'; import { validateFieldsInserted } from '@documenso/lib/utils/fields'; +import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field'; import { trpc } from '@documenso/trpc/react'; import type { TRemovedSignedFieldWithTokenMutationSchema, @@ -96,7 +103,7 @@ export const EmbedDirectTemplateClientPage = ({ const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page)); - const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE); + const hasSignatureField = localFields.some((field) => isSignatureFieldType(field.type)); const signatureValid = !hasSignatureField || (signature && signature.trim() !== ''); diff --git a/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx b/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx index 0d1c93b27..35af66e60 100644 --- a/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx +++ b/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx @@ -4,13 +4,14 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import type { DocumentMeta, EnvelopeItem } from '@prisma/client'; -import { type Field, FieldType, RecipientRole, SigningStatus } from '@prisma/client'; +import { type Field, RecipientRole, SigningStatus } from '@prisma/client'; import { LucideChevronDown, LucideChevronUp } from 'lucide-react'; import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; import { validateFieldsInserted } from '@documenso/lib/utils/fields'; +import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field'; import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; import { trpc } from '@documenso/trpc/react'; import { @@ -115,7 +116,7 @@ export const EmbedSignDocumentV1ClientPage = ({ [fields], ); - const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE); + const hasSignatureField = fields.some((field) => isSignatureFieldType(field.type)); const signatureValid = !hasSignatureField || (signature && signature.trim() !== ''); diff --git a/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx b/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx index 9d994c759..473722390 100644 --- a/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx +++ b/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx @@ -3,12 +3,13 @@ import { useState } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import { DocumentStatus, FieldType, SigningStatus } from '@prisma/client'; +import { DocumentStatus, SigningStatus } from '@prisma/client'; import { Loader, LucideChevronDown, LucideChevronUp, X } from 'lucide-react'; import { P, match } from 'ts-pattern'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field'; import { trpc } from '@documenso/trpc/react'; import type { TRemovedSignedFieldWithTokenMutationSchema, @@ -83,7 +84,7 @@ export const MultiSignDocumentSigningView = ({ const { mutateAsync: completeDocumentWithToken } = trpc.recipient.completeDocumentWithToken.useMutation(); - const hasSignatureField = document?.fields.some((field) => field.type === FieldType.SIGNATURE); + const hasSignatureField = document?.fields.some((field) => isSignatureFieldType(field.type)); const [pendingFields, completedFields] = [ document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ?? diff --git a/apps/remix/app/components/general/document-signing/document-signing-form.tsx b/apps/remix/app/components/general/document-signing/document-signing-form.tsx index 67f56a05a..709c70c8b 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-form.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-form.tsx @@ -3,7 +3,7 @@ import { useId, useMemo, useState } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/client'; +import { type Field, type Recipient, RecipientRole } from '@prisma/client'; import { Controller, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; @@ -11,6 +11,7 @@ import type { DocumentAndSender } from '@documenso/lib/server-only/document/get- import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; import { sortFieldsByPosition } from '@documenso/lib/utils/fields'; +import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field'; import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { Button } from '@documenso/ui/primitives/button'; @@ -78,7 +79,7 @@ export const DocumentSigningForm = ({ [fields], ); - const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE); + const hasSignatureField = fields.some((field) => isSignatureFieldType(field.type)); const uninsertedFields = useMemo(() => { return sortFieldsByPosition(fieldsRequiringValidation.filter((field) => !field.inserted)); diff --git a/apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx b/apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx index a9a65cef4..584c88940 100644 --- a/apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx +++ b/apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx @@ -1,8 +1,9 @@ import { useMemo } from 'react'; import { Plural, Trans } from '@lingui/react/macro'; -import { FieldType, RecipientRole } from '@prisma/client'; +import { RecipientRole } from '@prisma/client'; +import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field'; import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group'; @@ -30,7 +31,7 @@ export default function EnvelopeSignerForm() { const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {}; const hasSignatureField = useMemo(() => { - return recipientFields.some((field) => field.type === FieldType.SIGNATURE); + return recipientFields.some((field) => isSignatureFieldType(field.type)); }, [recipientFields]); const isSubmitting = false; diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index a92578034..4dba574ab 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -1041,12 +1041,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, { }, }; } catch (err) { - return { - status: 500, - body: { - message: 'An error has occured while sending the document for signing', - }, - }; + return AppError.toRestAPIError(err); } }), diff --git a/packages/app-tests/e2e/api/v1/document-sending.spec.ts b/packages/app-tests/e2e/api/v1/document-sending.spec.ts index 4723b4690..ae1a28564 100644 --- a/packages/app-tests/e2e/api/v1/document-sending.spec.ts +++ b/packages/app-tests/e2e/api/v1/document-sending.spec.ts @@ -4,7 +4,11 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; import { prisma } from '@documenso/prisma'; -import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents'; +import { FieldType, RecipientRole } from '@documenso/prisma/client'; +import { + seedBlankDocument, + seedPendingDocumentWithFullFields, +} from '@documenso/prisma/seed/documents'; import { seedUser } from '@documenso/prisma/seed/users'; test.describe('Document API', () => { @@ -145,4 +149,293 @@ test.describe('Document API', () => { ownerDocumentCompleted: false, }); }); + + test('sendDocument: should fail when signer has no signature field', async ({ request }) => { + const { user, team } = await seedUser(); + + // Create a blank document and get it with envelope items + const blankDocument = await seedBlankDocument(user, team.id); + const document = await prisma.envelope.findUniqueOrThrow({ + where: { id: blankDocument.id }, + include: { envelopeItems: true }, + }); + + // Add a signer recipient without any fields + await prisma.recipient.create({ + data: { + email: 'signer@example.com', + name: 'Test Signer', + role: RecipientRole.SIGNER, + token: 'test-token-1', + envelopeId: document.id, + }, + }); + + const { token } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'test', + expiresIn: null, + }); + + const response = await request.post( + `${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`, + { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data: {}, + }, + ); + + expect(response.ok()).toBeFalsy(); + expect(response.status()).toBe(400); + }); + + test('sendDocument: should fail when signer has only non-signature fields', async ({ + request, + }) => { + const { user, team } = await seedUser(); + + // Create a blank document and get it with envelope items + const blankDocument = await seedBlankDocument(user, team.id); + const document = await prisma.envelope.findUniqueOrThrow({ + where: { id: blankDocument.id }, + include: { envelopeItems: true }, + }); + + // Add a signer recipient with only a TEXT field (not signature) + const recipient = await prisma.recipient.create({ + data: { + email: 'signer@example.com', + name: 'Test Signer', + role: RecipientRole.SIGNER, + token: 'test-token-2', + envelopeId: document.id, + }, + }); + + // Add a TEXT field (not a signature field) + await prisma.field.create({ + data: { + type: FieldType.TEXT, + page: 1, + positionX: 100, + positionY: 100, + width: 50, + height: 50, + customText: '', + inserted: false, + recipientId: recipient.id, + envelopeId: document.id, + envelopeItemId: document.envelopeItems[0].id, + }, + }); + + const { token } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'test', + expiresIn: null, + }); + + const response = await request.post( + `${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`, + { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data: {}, + }, + ); + + expect(response.ok()).toBeFalsy(); + expect(response.status()).toBe(400); + }); + + test('sendDocument: should succeed when signer has signature field', async ({ request }) => { + const { user, team } = await seedUser(); + + const { document } = await seedPendingDocumentWithFullFields({ + owner: user, + recipients: ['signer@example.com'], + teamId: team.id, + }); + + const { token } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'test', + expiresIn: null, + }); + + const response = await request.post( + `${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`, + { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data: {}, + }, + ); + + expect(response.ok()).toBeTruthy(); + expect(response.status()).toBe(200); + }); + + test('sendDocument: should succeed when signer has FREE_SIGNATURE field', async ({ request }) => { + const { user, team } = await seedUser(); + + // Create a blank document and get it with envelope items + const blankDocument = await seedBlankDocument(user, team.id); + const document = await prisma.envelope.findUniqueOrThrow({ + where: { id: blankDocument.id }, + include: { envelopeItems: true }, + }); + + // Add a signer recipient + const recipient = await prisma.recipient.create({ + data: { + email: 'signer@example.com', + name: 'Test Signer', + role: RecipientRole.SIGNER, + token: 'test-token-3', + envelopeId: document.id, + }, + }); + + // Add a FREE_SIGNATURE field + await prisma.field.create({ + data: { + type: FieldType.FREE_SIGNATURE, + page: 1, + positionX: 100, + positionY: 100, + width: 50, + height: 50, + customText: '', + inserted: false, + recipientId: recipient.id, + envelopeId: document.id, + envelopeItemId: document.envelopeItems[0].id, + }, + }); + + const { token } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'test', + expiresIn: null, + }); + + const response = await request.post( + `${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`, + { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data: {}, + }, + ); + + expect(response.ok()).toBeTruthy(); + expect(response.status()).toBe(200); + }); + + test('sendDocument: should succeed when non-signer roles have no fields', async ({ request }) => { + const { user, team } = await seedUser(); + + // Create a blank document and get it with envelope items + const blankDocument = await seedBlankDocument(user, team.id); + const document = await prisma.envelope.findUniqueOrThrow({ + where: { id: blankDocument.id }, + include: { envelopeItems: true }, + }); + + // Add a signer with signature field + const signer = await prisma.recipient.create({ + data: { + email: 'signer@example.com', + name: 'Test Signer', + role: RecipientRole.SIGNER, + token: 'test-token-4', + envelopeId: document.id, + }, + }); + + await prisma.field.create({ + data: { + type: FieldType.SIGNATURE, + page: 1, + positionX: 1, + positionY: 1, + width: 1, + height: 1, + customText: '', + inserted: false, + recipientId: signer.id, + envelopeId: document.id, + envelopeItemId: document.envelopeItems[0].id, + fieldMeta: { type: 'signature', fontSize: 14 }, + }, + }); + + // Add a viewer without any fields + await prisma.recipient.create({ + data: { + email: 'viewer@example.com', + name: 'Test Viewer', + role: RecipientRole.VIEWER, + token: 'test-token-5', + envelopeId: document.id, + }, + }); + + // Add an approver without any fields + await prisma.recipient.create({ + data: { + email: 'approver@example.com', + name: 'Test Approver', + role: RecipientRole.APPROVER, + token: 'test-token-6', + envelopeId: document.id, + }, + }); + + // Add a CC without any fields + await prisma.recipient.create({ + data: { + email: 'cc@example.com', + name: 'Test CC', + role: RecipientRole.CC, + token: 'test-token-7', + envelopeId: document.id, + }, + }); + + const { token } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'test', + expiresIn: null, + }); + + const response = await request.post( + `${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`, + { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data: {}, + }, + ); + + expect(response.ok()).toBeTruthy(); + expect(response.status()).toBe(200); + }); }); diff --git a/packages/app-tests/e2e/api/v1/template-field-prefill.spec.ts b/packages/app-tests/e2e/api/v1/template-field-prefill.spec.ts index 799e08c7d..15bd2a43e 100644 --- a/packages/app-tests/e2e/api/v1/template-field-prefill.spec.ts +++ b/packages/app-tests/e2e/api/v1/template-field-prefill.spec.ts @@ -171,6 +171,24 @@ test.describe('Template Field Prefill API v1', () => { }, }); + // Add SIGNATURE field (required for distribution) + await prisma.field.create({ + data: { + envelopeId: template.id, + envelopeItemId: firstEnvelopeItem.id, + recipientId: recipient.id, + type: FieldType.SIGNATURE, + page: 1, + positionX: 1, + positionY: 1, + width: 1, + height: 1, + customText: '', + inserted: false, + fieldMeta: { type: 'signature', fontSize: 14 }, + }, + }); + // 6. Sign in as the user await apiSignin({ page, @@ -444,6 +462,24 @@ test.describe('Template Field Prefill API v1', () => { }, }); + // Add SIGNATURE field (required for distribution) + await prisma.field.create({ + data: { + envelopeId: template.id, + envelopeItemId: firstEnvelopeItem.id, + recipientId: recipient.id, + type: FieldType.SIGNATURE, + page: 1, + positionX: 1, + positionY: 1, + width: 1, + height: 1, + customText: '', + inserted: false, + fieldMeta: { type: 'signature', fontSize: 14 }, + }, + }); + // 6. Sign in as the user await apiSignin({ page, diff --git a/packages/app-tests/e2e/api/v1/test-unauthorized-api-access.spec.ts b/packages/app-tests/e2e/api/v1/test-unauthorized-api-access.spec.ts index 5efb1a076..732ecf991 100644 --- a/packages/app-tests/e2e/api/v1/test-unauthorized-api-access.spec.ts +++ b/packages/app-tests/e2e/api/v1/test-unauthorized-api-access.spec.ts @@ -221,7 +221,7 @@ test.describe('Document Access API V1', () => { ); expect(resB.ok()).toBeFalsy(); - expect(resB.status()).toBe(500); + expect(resB.status()).toBe(404); }); test('should allow authorized access to document send endpoint', async ({ request }) => { diff --git a/packages/app-tests/e2e/api/v2/distribute-validation.spec.ts b/packages/app-tests/e2e/api/v2/distribute-validation.spec.ts new file mode 100644 index 000000000..15ec3eff7 --- /dev/null +++ b/packages/app-tests/e2e/api/v2/distribute-validation.spec.ts @@ -0,0 +1,403 @@ +import { type APIRequestContext, expect, test } from '@playwright/test'; +import type { Team, User } from '@prisma/client'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; +import { EnvelopeType, FieldType, RecipientRole } from '@documenso/prisma/client'; +import { seedUser } from '@documenso/prisma/seed/users'; +import type { + TCreateEnvelopePayload, + TCreateEnvelopeResponse, +} from '@documenso/trpc/server/envelope-router/create-envelope.types'; +import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types'; +import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); +const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`; + +test.describe.configure({ + mode: 'parallel', +}); + +test.describe('Envelope distribute validation', () => { + let user: User, team: Team, token: string; + + test.beforeEach(async () => { + ({ user, team } = await seedUser()); + ({ token } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'test', + expiresIn: null, + })); + }); + + const createEnvelope = async (request: APIRequestContext, authToken: string) => { + const payload: TCreateEnvelopePayload = { + type: EnvelopeType.DOCUMENT, + title: 'Test Document', + }; + + const formData = new FormData(); + formData.append('payload', JSON.stringify(payload)); + + const pdfData = fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')); + formData.append('files', new File([pdfData], 'test.pdf', { type: 'application/pdf' })); + + const res = await request.post(`${baseUrl}/envelope/create`, { + headers: { Authorization: `Bearer ${authToken}` }, + multipart: formData, + }); + + expect(res.ok()).toBeTruthy(); + return (await res.json()) as TCreateEnvelopeResponse; + }; + + const getEnvelope = async (request: APIRequestContext, authToken: string, envelopeId: string) => { + const res = await request.get(`${baseUrl}/envelope/${envelopeId}`, { + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(res.ok()).toBeTruthy(); + return (await res.json()) as TGetEnvelopeResponse; + }; + + const createRecipients = async ( + request: APIRequestContext, + authToken: string, + envelopeId: string, + recipients: TCreateEnvelopeRecipientsRequest['data'], + ) => { + const res = await request.post(`${baseUrl}/envelope/recipient/create-many`, { + headers: { Authorization: `Bearer ${authToken}` }, + data: { + envelopeId, + data: recipients, + } satisfies TCreateEnvelopeRecipientsRequest, + }); + + expect(res.ok()).toBeTruthy(); + return (await res.json()).data; + }; + + const createFields = async ( + request: APIRequestContext, + authToken: string, + envelopeId: string, + envelopeItemId: string, + fields: Array<{ recipientId: number; type: FieldType }>, + ) => { + const res = await request.post(`${baseUrl}/envelope/field/create-many`, { + headers: { Authorization: `Bearer ${authToken}` }, + data: { + envelopeId, + data: fields.map((field, index) => ({ + recipientId: field.recipientId, + envelopeItemId, + type: field.type, + page: 1, + positionX: 10, + positionY: 10 + index * 10, + width: 10, + height: 10, + })), + }, + }); + + expect(res.ok()).toBeTruthy(); + return (await res.json()).data; + }; + + test('should fail to distribute when signer has no fields', async ({ request }) => { + const envelope = await createEnvelope(request, token); + + // Create a signer without any fields + await createRecipients(request, token, envelope.id, [ + { + email: 'signer@example.com', + name: 'Test Signer', + role: RecipientRole.SIGNER, + accessAuth: [], + actionAuth: [], + }, + ]); + + // Try to distribute without adding any fields + const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, { + headers: { Authorization: `Bearer ${token}` }, + data: { envelopeId: envelope.id }, + }); + + expect(distributeRes.ok()).toBeFalsy(); + expect(distributeRes.status()).toBe(400); + + const errorResponse = await distributeRes.json(); + expect(errorResponse.message).toContain('missing required fields'); + expect(errorResponse.message).toContain('Signers must have at least one signature field'); + }); + + test('should fail to distribute when signer has non-signature fields only', async ({ + request, + }) => { + const envelope = await createEnvelope(request, token); + const envelopeData = await getEnvelope(request, token, envelope.id); + + // Create a signer + const recipients = await createRecipients(request, token, envelope.id, [ + { + email: 'signer@example.com', + name: 'Test Signer', + role: RecipientRole.SIGNER, + accessAuth: [], + actionAuth: [], + }, + ]); + + // Add only a TEXT field (not a signature field) + await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [ + { recipientId: recipients[0].id, type: FieldType.TEXT }, + ]); + + // Try to distribute + const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, { + headers: { Authorization: `Bearer ${token}` }, + data: { envelopeId: envelope.id }, + }); + + expect(distributeRes.ok()).toBeFalsy(); + expect(distributeRes.status()).toBe(400); + + const errorResponse = await distributeRes.json(); + expect(errorResponse.message).toContain('missing required fields'); + }); + + test('should succeed when signer has SIGNATURE field', async ({ request }) => { + const envelope = await createEnvelope(request, token); + const envelopeData = await getEnvelope(request, token, envelope.id); + + // Create a signer + const recipients = await createRecipients(request, token, envelope.id, [ + { + email: 'signer@example.com', + name: 'Test Signer', + role: RecipientRole.SIGNER, + accessAuth: [], + actionAuth: [], + }, + ]); + + // Add a SIGNATURE field + await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [ + { recipientId: recipients[0].id, type: FieldType.SIGNATURE }, + ]); + + // Distribute should succeed + const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, { + headers: { Authorization: `Bearer ${token}` }, + data: { envelopeId: envelope.id }, + }); + + expect(distributeRes.ok()).toBeTruthy(); + expect(distributeRes.status()).toBe(200); + + const response = await distributeRes.json(); + expect(response.success).toBe(true); + }); + + // Note: FREE_SIGNATURE field type is not supported via the v2 API for field creation, + // so we only test with SIGNATURE fields here. The v1 tests cover FREE_SIGNATURE + // using direct Prisma creation. + + test('should succeed when VIEWER has no fields', async ({ request }) => { + const envelope = await createEnvelope(request, token); + const envelopeData = await getEnvelope(request, token, envelope.id); + + // Create a signer and a viewer + const recipients = await createRecipients(request, token, envelope.id, [ + { + email: 'signer@example.com', + name: 'Test Signer', + role: RecipientRole.SIGNER, + accessAuth: [], + actionAuth: [], + }, + { + email: 'viewer@example.com', + name: 'Test Viewer', + role: RecipientRole.VIEWER, + accessAuth: [], + actionAuth: [], + }, + ]); + + // Add signature field only for the signer (viewer has no fields) + await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [ + { recipientId: recipients[0].id, type: FieldType.SIGNATURE }, + ]); + + // Distribute should succeed + const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, { + headers: { Authorization: `Bearer ${token}` }, + data: { envelopeId: envelope.id }, + }); + + expect(distributeRes.ok()).toBeTruthy(); + expect(distributeRes.status()).toBe(200); + }); + + test('should succeed when CC has no fields', async ({ request }) => { + const envelope = await createEnvelope(request, token); + const envelopeData = await getEnvelope(request, token, envelope.id); + + // Create a signer and a CC recipient + const recipients = await createRecipients(request, token, envelope.id, [ + { + email: 'signer@example.com', + name: 'Test Signer', + role: RecipientRole.SIGNER, + accessAuth: [], + actionAuth: [], + }, + { + email: 'cc@example.com', + name: 'Test CC', + role: RecipientRole.CC, + accessAuth: [], + actionAuth: [], + }, + ]); + + // Add signature field only for the signer (CC has no fields) + await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [ + { recipientId: recipients[0].id, type: FieldType.SIGNATURE }, + ]); + + // Distribute should succeed + const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, { + headers: { Authorization: `Bearer ${token}` }, + data: { envelopeId: envelope.id }, + }); + + expect(distributeRes.ok()).toBeTruthy(); + expect(distributeRes.status()).toBe(200); + }); + + test('should succeed when APPROVER has no fields', async ({ request }) => { + const envelope = await createEnvelope(request, token); + const envelopeData = await getEnvelope(request, token, envelope.id); + + // Create a signer and an approver + const recipients = await createRecipients(request, token, envelope.id, [ + { + email: 'signer@example.com', + name: 'Test Signer', + role: RecipientRole.SIGNER, + accessAuth: [], + actionAuth: [], + }, + { + email: 'approver@example.com', + name: 'Test Approver', + role: RecipientRole.APPROVER, + accessAuth: [], + actionAuth: [], + }, + ]); + + // Add signature field only for the signer (approver has no fields) + await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [ + { recipientId: recipients[0].id, type: FieldType.SIGNATURE }, + ]); + + // Distribute should succeed + const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, { + headers: { Authorization: `Bearer ${token}` }, + data: { envelopeId: envelope.id }, + }); + + expect(distributeRes.ok()).toBeTruthy(); + expect(distributeRes.status()).toBe(200); + }); + + test('should fail when one of multiple signers is missing signature field', async ({ + request, + }) => { + const envelope = await createEnvelope(request, token); + const envelopeData = await getEnvelope(request, token, envelope.id); + + // Create two signers + const recipients = await createRecipients(request, token, envelope.id, [ + { + email: 'signer1@example.com', + name: 'Test Signer 1', + role: RecipientRole.SIGNER, + accessAuth: [], + actionAuth: [], + }, + { + email: 'signer2@example.com', + name: 'Test Signer 2', + role: RecipientRole.SIGNER, + accessAuth: [], + actionAuth: [], + }, + ]); + + // Add signature field only for the first signer + await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [ + { recipientId: recipients[0].id, type: FieldType.SIGNATURE }, + ]); + + // Distribute should fail because second signer has no signature field + const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, { + headers: { Authorization: `Bearer ${token}` }, + data: { envelopeId: envelope.id }, + }); + + expect(distributeRes.ok()).toBeFalsy(); + expect(distributeRes.status()).toBe(400); + + const errorResponse = await distributeRes.json(); + expect(errorResponse.message).toContain('missing required fields'); + }); + + test('should succeed when all signers have signature fields', async ({ request }) => { + const envelope = await createEnvelope(request, token); + const envelopeData = await getEnvelope(request, token, envelope.id); + + // Create two signers + const recipients = await createRecipients(request, token, envelope.id, [ + { + email: 'signer1@example.com', + name: 'Test Signer 1', + role: RecipientRole.SIGNER, + accessAuth: [], + actionAuth: [], + }, + { + email: 'signer2@example.com', + name: 'Test Signer 2', + role: RecipientRole.SIGNER, + accessAuth: [], + actionAuth: [], + }, + ]); + + // Add signature fields for both signers + await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [ + { recipientId: recipients[0].id, type: FieldType.SIGNATURE }, + { recipientId: recipients[1].id, type: FieldType.SIGNATURE }, + ]); + + // Distribute should succeed + const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, { + headers: { Authorization: `Bearer ${token}` }, + data: { envelopeId: envelope.id }, + }); + + expect(distributeRes.ok()).toBeTruthy(); + expect(distributeRes.status()).toBe(200); + }); +}); diff --git a/packages/app-tests/e2e/api/v2/template-field-prefill.spec.ts b/packages/app-tests/e2e/api/v2/template-field-prefill.spec.ts index f0835a000..97525de6c 100644 --- a/packages/app-tests/e2e/api/v2/template-field-prefill.spec.ts +++ b/packages/app-tests/e2e/api/v2/template-field-prefill.spec.ts @@ -171,6 +171,24 @@ test.describe('Template Field Prefill API v2', () => { }, }); + // Add SIGNATURE field (required for distribution) + await prisma.field.create({ + data: { + envelopeId: template.id, + envelopeItemId: firstEnvelopeItem.id, + recipientId: recipient.id, + type: FieldType.SIGNATURE, + page: 1, + positionX: 1, + positionY: 1, + width: 1, + height: 1, + customText: '', + inserted: false, + fieldMeta: { type: 'signature', fontSize: 14 }, + }, + }); + // 6. Sign in as the user await apiSignin({ page, @@ -441,6 +459,24 @@ test.describe('Template Field Prefill API v2', () => { }, }); + // Add SIGNATURE field (required for distribution) + await prisma.field.create({ + data: { + envelopeId: template.id, + envelopeItemId: firstEnvelopeItem.id, + recipientId: recipient.id, + type: FieldType.SIGNATURE, + page: 1, + positionX: 1, + positionY: 1, + width: 1, + height: 1, + customText: '', + inserted: false, + fieldMeta: { type: 'signature', fontSize: 14 }, + }, + }); + // 6. Sign in as the user await apiSignin({ page, diff --git a/packages/app-tests/e2e/api/v2/test-unauthorized-api-access.spec.ts b/packages/app-tests/e2e/api/v2/test-unauthorized-api-access.spec.ts index fd070f183..5dc4794e3 100644 --- a/packages/app-tests/e2e/api/v2/test-unauthorized-api-access.spec.ts +++ b/packages/app-tests/e2e/api/v2/test-unauthorized-api-access.spec.ts @@ -195,6 +195,31 @@ test.describe('Document API V2', () => { }) => { const doc = await seedDraftDocument(userA, teamA.id, ['test@example.com']); + // Get the recipient created during seeding. + const recipient = await prisma.recipient.findFirstOrThrow({ + where: { + envelopeId: doc.id, + }, + }); + + // Create a signature field for the recipient so distribution validation can run. + await prisma.field.create({ + data: { + envelopeId: doc.id, + envelopeItemId: doc.envelopeItems[0].id, + recipientId: recipient.id, + type: FieldType.SIGNATURE, + page: 1, + positionX: 1, + positionY: 1, + width: 1, + height: 1, + customText: '', + inserted: false, + fieldMeta: { type: 'signature', fontSize: 14 }, + }, + }); + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/document/distribute`, { headers: { Authorization: `Bearer ${tokenB}` }, data: { documentId: mapSecondaryIdToDocumentId(doc.secondaryId) }, @@ -207,6 +232,31 @@ test.describe('Document API V2', () => { test('should allow authorized access to document distribute endpoint', async ({ request }) => { const doc = await seedDraftDocument(userA, teamA.id, ['test@example.com']); + // Get the recipient created during seeding. + const recipient = await prisma.recipient.findFirstOrThrow({ + where: { + envelopeId: doc.id, + }, + }); + + // Create a signature field for the recipient so distribution validation can run. + await prisma.field.create({ + data: { + envelopeId: doc.id, + envelopeItemId: doc.envelopeItems[0].id, + recipientId: recipient.id, + type: FieldType.SIGNATURE, + page: 1, + positionX: 1, + positionY: 1, + width: 1, + height: 1, + customText: '', + inserted: false, + fieldMeta: { type: 'signature', fontSize: 14 }, + }, + }); + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/document/distribute`, { headers: { Authorization: `Bearer ${tokenA}` }, data: { documentId: mapSecondaryIdToDocumentId(doc.secondaryId) }, @@ -3678,6 +3728,26 @@ test.describe('Document API V2', () => { internalVersion: 2, }); + const [recipient] = doc.recipients; + + // add signing field for recipient (fieldMeta required for v2 envelopes) + await prisma.field.create({ + data: { + page: 1, + type: FieldType.SIGNATURE, + inserted: false, + customText: '', + positionX: 1, + positionY: 1, + width: 1, + height: 1, + envelopeId: doc.id, + envelopeItemId: doc.envelopeItems[0].id, + recipientId: recipient.id, + fieldMeta: { type: 'signature', fontSize: 14 }, + }, + }); + const payload: TUseEnvelopePayload = { envelopeId: doc.id, distributeDocument: true, @@ -3741,6 +3811,31 @@ test.describe('Document API V2', () => { }) => { const doc = await seedDraftDocument(userA, teamA.id, ['test@example.com']); + // Get the recipient created during seeding. + const recipient = await prisma.recipient.findFirstOrThrow({ + where: { + envelopeId: doc.id, + }, + }); + + // Create a signature field for the recipient so distribution validation can pass. + await prisma.field.create({ + data: { + envelopeId: doc.id, + envelopeItemId: doc.envelopeItems[0].id, + recipientId: recipient.id, + type: FieldType.SIGNATURE, + page: 1, + positionX: 1, + positionY: 1, + width: 1, + height: 1, + customText: '', + inserted: false, + fieldMeta: { type: 'signature', fontSize: 14 }, + }, + }); + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/distribute`, { headers: { Authorization: `Bearer ${tokenA}` }, data: { envelopeId: doc.id }, diff --git a/packages/app-tests/e2e/document-flow/duplicate-recipients.spec.ts b/packages/app-tests/e2e/document-flow/duplicate-recipients.spec.ts index 0811db244..6eb2bb370 100644 --- a/packages/app-tests/e2e/document-flow/duplicate-recipients.spec.ts +++ b/packages/app-tests/e2e/document-flow/duplicate-recipients.spec.ts @@ -257,10 +257,12 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => { // Change second recipient role if role selector is available const roleDropdown = page.getByLabel('Role').nth(1); + let secondRecipientIsApprover = false; if (await roleDropdown.isVisible()) { await roleDropdown.click(); await page.getByText('Approver').click(); + secondRecipientIsApprover = true; } // Step 3: Add different field types for each duplicate @@ -281,6 +283,13 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => { await page.getByRole('button', { name: 'Date' }).click(); await page.locator('canvas').click({ position: { x: 200, y: 150 } }); + // If second recipient is still a SIGNER (role change wasn't available), + // add a signature field for them to pass validation + if (!secondRecipientIsApprover) { + await page.getByRole('button', { name: 'Signature' }).click(); + await page.locator('canvas').click({ position: { x: 200, y: 200 } }); + } + // Complete the document await page.getByRole('button', { name: 'Continue' }).click(); diff --git a/packages/app-tests/e2e/templates-flow/duplicate-recipients.spec.ts b/packages/app-tests/e2e/templates-flow/duplicate-recipients.spec.ts index 7a93e3d21..8982aef52 100644 --- a/packages/app-tests/e2e/templates-flow/duplicate-recipients.spec.ts +++ b/packages/app-tests/e2e/templates-flow/duplicate-recipients.spec.ts @@ -50,11 +50,13 @@ const completeTemplateFlowWithDuplicateRecipients = async (options: { await page.getByRole('button', { name: 'Signature' }).click(); await page.locator('canvas').click({ position: { x: 200, y: 100 } }); - // Switch to different recipient and add their field + // Switch to different recipient and add their fields await page.getByRole('combobox').first().click(); await page.getByText('Different Recipient').first().click(); - await page.getByRole('button', { name: 'Name' }).click(); + await page.getByRole('button', { name: 'Signature' }).click(); await page.locator('canvas').click({ position: { x: 300, y: 100 } }); + await page.getByRole('button', { name: 'Name' }).click(); + await page.locator('canvas').click({ position: { x: 300, y: 150 } }); // Save template await page.getByRole('button', { name: 'Save Template' }).click(); diff --git a/packages/lib/server-only/document/send-document.ts b/packages/lib/server-only/document/send-document.ts index 4e7a49a12..4c6ae4493 100644 --- a/packages/lib/server-only/document/send-document.ts +++ b/packages/lib/server-only/document/send-document.ts @@ -38,7 +38,10 @@ import { isDocumentCompleted } from '../../utils/document'; import { extractDocumentAuthMethods } from '../../utils/document-auth'; import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope'; import { toCheckboxCustomText, toRadioCustomText } from '../../utils/fields'; -import { isRecipientEmailValidForSending } from '../../utils/recipients'; +import { + getRecipientsWithMissingFields, + isRecipientEmailValidForSending, +} from '../../utils/recipients'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; @@ -148,30 +151,19 @@ export const sendDocument = async ({ } }); - // Commented out server side checks for minimum 1 signature per signer now since we need to - // decide if we want to enforce this for API & templates. - // const fields = await getFieldsForDocument({ - // documentId: documentId, - // userId: userId, - // }); + // Validate that recipients who require fields (e.g., signers need signature fields) have them. + const recipientsWithMissingFields = getRecipientsWithMissingFields( + envelope.recipients, + envelope.fields, + ); - // const fieldsWithSignerEmail = fields.map((field) => ({ - // ...field, - // signerEmail: - // envelope.Recipient.find((recipient) => recipient.id === field.recipientId)?.email ?? '', - // })); + if (recipientsWithMissingFields.length > 0) { + const missingRecipientIds = recipientsWithMissingFields.map((r) => r.id).join(', '); - // const everySignerHasSignature = document?.Recipient.every( - // (recipient) => - // recipient.role !== RecipientRole.SIGNER || - // fieldsWithSignerEmail.some( - // (field) => field.type === 'SIGNATURE' && field.signerEmail === recipient.email, - // ), - // ); - - // if (!everySignerHasSignature) { - // throw new Error('Some signers have not been assigned a signature field.'); - // } + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: `The following recipients are missing required fields: ${missingRecipientIds}. Signers must have at least one signature field.`, + }); + } const allRecipientsHaveNoActionToTake = envelope.recipients.every( (recipient) => diff --git a/packages/lib/utils/recipients.ts b/packages/lib/utils/recipients.ts index 8518ea95b..aa3021324 100644 --- a/packages/lib/utils/recipients.ts +++ b/packages/lib/utils/recipients.ts @@ -2,9 +2,40 @@ import type { Envelope } from '@prisma/client'; import { type Field, type Recipient, RecipientRole, SigningStatus } from '@prisma/client'; import { z } from 'zod'; +import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field'; + import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app'; import { extractLegacyIds } from '../universal/id'; +/** + * Roles that require fields to be assigned before a document can be distributed. + * + * Currently only SIGNER requires a signature field. + */ +export const RECIPIENT_ROLES_THAT_REQUIRE_FIELDS = [RecipientRole.SIGNER] as const; + +/** + * Returns recipients who are missing required fields for their role. + * + * Currently only SIGNERs are validated - they must have at least one signature field. + */ +export const getRecipientsWithMissingFields = >( + recipients: T[], + fields: Pick[], +): T[] => { + return recipients.filter((recipient) => { + if (recipient.role === RecipientRole.SIGNER) { + const hasSignatureField = fields.some( + (field) => field.recipientId === recipient.id && isSignatureFieldType(field.type), + ); + + return !hasSignatureField; + } + + return false; + }); +}; + export const formatSigningLink = (token: string) => `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${token}`; /** diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx index 21f97c314..1a185f1bb 100644 --- a/packages/ui/primitives/document-flow/add-fields.tsx +++ b/packages/ui/primitives/document-flow/add-fields.tsx @@ -35,6 +35,7 @@ import { parseMessageDescriptor } from '@documenso/lib/utils/i18n'; import { canRecipientBeModified, canRecipientFieldsBeModified, + getRecipientsWithMissingFields, } from '@documenso/lib/utils/recipients'; import { FieldToolTip } from '../../components/field/field-tooltip'; @@ -555,15 +556,11 @@ export const AddFieldsFormPartial = ({ }; const handleGoNextClick = () => { - const everySignerHasSignature = recipientsByRole.SIGNER.every((signer) => - localFields.some( - (field) => - (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) && - field.signerEmail === signer.email, - ), - ); + // localFields already have recipientId set correctly (see field creation at line 338) + // Using the existing recipientId is important for handling duplicate email recipients + const recipientsMissingFields = getRecipientsWithMissingFields(recipients, localFields); - if (!everySignerHasSignature) { + if (recipientsMissingFields.length > 0) { setIsMissingSignatureDialogVisible(true); return; }