diff --git a/apps/remix/app/components/dialogs/template-use-dialog.tsx b/apps/remix/app/components/dialogs/template-use-dialog.tsx index 3d4e7ba61..f5822b2f9 100644 --- a/apps/remix/app/components/dialogs/template-use-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-use-dialog.tsx @@ -15,7 +15,6 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX, TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX, - isTemplateRecipientEmailPlaceholder, } from '@documenso/lib/constants/template'; import { AppError } from '@documenso/lib/errors/app-error'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; @@ -46,50 +45,22 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive import type { Toast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast'; -const ZAddRecipientsForNewDocumentSchema = z - .object({ - distributeDocument: z.boolean(), - useCustomDocument: z.boolean().default(false), - customDocumentData: z - .any() - .refine((data) => data instanceof File || data === undefined) - .optional(), - recipients: z.array( - z.object({ - id: z.number(), - email: z.string().email(), - name: z.string(), - signingOrder: z.number().optional(), - }), - ), - }) - // Display exactly which rows are duplicates. - .superRefine((items, ctx) => { - const uniqueEmails = new Map(); - - for (const [index, recipients] of items.recipients.entries()) { - const email = recipients.email.toLowerCase(); - - const firstFoundIndex = uniqueEmails.get(email); - - if (firstFoundIndex === undefined) { - uniqueEmails.set(email, index); - continue; - } - - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Emails must be unique', - path: ['recipients', index, 'email'], - }); - - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Emails must be unique', - path: ['recipients', firstFoundIndex, 'email'], - }); - } - }); +const ZAddRecipientsForNewDocumentSchema = z.object({ + distributeDocument: z.boolean(), + useCustomDocument: z.boolean().default(false), + customDocumentData: z + .any() + .refine((data) => data instanceof File || data === undefined) + .optional(), + recipients: z.array( + z.object({ + id: z.number(), + email: z.string().email(), + name: z.string(), + signingOrder: z.number().optional(), + }), + ), +}); type TAddRecipientsForNewDocumentSchema = z.infer; @@ -278,14 +249,7 @@ export function TemplateUseDialog({ )} - + @@ -306,6 +270,7 @@ export function TemplateUseDialog({ diff --git a/apps/remix/app/components/general/document/document-edit-form.tsx b/apps/remix/app/components/general/document/document-edit-form.tsx index e8ffa5fe5..023a20a8b 100644 --- a/apps/remix/app/components/general/document/document-edit-form.tsx +++ b/apps/remix/app/components/general/document/document-edit-form.tsx @@ -239,7 +239,27 @@ export const DocumentEditForm = ({ const onAddSignersFormAutoSave = async (data: TAddSignersFormSchema) => { try { - await saveSignersData(data); + // For autosave, we need to return the recipients response for form state sync + const [, recipientsResponse] = await Promise.all([ + updateDocument({ + documentId: document.id, + meta: { + allowDictateNextSigner: data.allowDictateNextSigner, + signingOrder: data.signingOrder, + }, + }), + + setRecipients({ + documentId: document.id, + recipients: data.signers.map((signer) => ({ + ...signer, + // Explicitly set to null to indicate we want to remove auth if required. + actionAuth: signer.actionAuth ?? [], + })), + }), + ]); + + return recipientsResponse; } catch (err) { console.error(err); @@ -248,6 +268,8 @@ export const DocumentEditForm = ({ description: _(msg`An error occurred while adding signers.`), variant: 'destructive', }); + + throw err; // Re-throw so the autosave hook can handle the error } }; diff --git a/apps/remix/app/components/general/folder/folder-card.tsx b/apps/remix/app/components/general/folder/folder-card.tsx index a8d2d3bac..db88ebb7f 100644 --- a/apps/remix/app/components/general/folder/folder-card.tsx +++ b/apps/remix/app/components/general/folder/folder-card.tsx @@ -54,7 +54,7 @@ export const FolderCard = ({ }; return ( - +
diff --git a/apps/remix/app/components/general/template/template-edit-form.tsx b/apps/remix/app/components/general/template/template-edit-form.tsx index 17d7a45a1..41002b54e 100644 --- a/apps/remix/app/components/general/template/template-edit-form.tsx +++ b/apps/remix/app/components/general/template/template-edit-form.tsx @@ -182,7 +182,7 @@ export const TemplateEditForm = ({ }; const saveTemplatePlaceholderData = async (data: TAddTemplatePlacholderRecipientsFormSchema) => { - return Promise.all([ + const [, recipients] = await Promise.all([ updateTemplateSettings({ templateId: template.id, meta: { @@ -196,6 +196,8 @@ export const TemplateEditForm = ({ recipients: data.signers, }), ]); + + return recipients; }; const onAddTemplatePlaceholderFormSubmit = async ( @@ -218,7 +220,7 @@ export const TemplateEditForm = ({ data: TAddTemplatePlacholderRecipientsFormSchema, ) => { try { - await saveTemplatePlaceholderData(data); + return await saveTemplatePlaceholderData(data); } catch (err) { console.error(err); @@ -227,6 +229,8 @@ export const TemplateEditForm = ({ description: _(msg`An error occurred while auto-saving the template placeholders.`), variant: 'destructive', }); + + throw err; } }; diff --git a/packages/api/v1/schema.ts b/packages/api/v1/schema.ts index b8e31bfe0..5b4b33f7b 100644 --- a/packages/api/v1/schema.ts +++ b/packages/api/v1/schema.ts @@ -310,12 +310,11 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({ ) .refine( (schema) => { - const emails = schema.map((signer) => signer.email.toLowerCase()); const ids = schema.map((signer) => signer.id); - return new Set(emails).size === emails.length && new Set(ids).size === ids.length; + return new Set(ids).size === ids.length; }, - { message: 'Recipient IDs and emails must be unique' }, + { message: 'Recipient IDs must be unique' }, ), meta: z .object({ diff --git a/packages/app-tests/e2e/document-flow/autosave-fields-step.spec.ts b/packages/app-tests/e2e/document-flow/autosave-fields-step.spec.ts index 247f87319..73859de20 100644 --- a/packages/app-tests/e2e/document-flow/autosave-fields-step.spec.ts +++ b/packages/app-tests/e2e/document-flow/autosave-fields-step.spec.ts @@ -33,7 +33,7 @@ const setupDocumentAndNavigateToFieldsStep = async (page: Page) => { }; const triggerAutosave = async (page: Page) => { - await page.locator('#document-flow-form-container').click(); + await page.locator('body').click({ position: { x: 0, y: 0 } }); await page.locator('#document-flow-form-container').blur(); await page.waitForTimeout(5000); @@ -70,7 +70,7 @@ test.describe('AutoSave Fields Step', () => { await triggerAutosave(page); - await page.getByRole('combobox').click(); + await page.getByRole('combobox').first().click(); await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click(); await page.getByRole('button', { name: 'Signature' }).click(); @@ -127,7 +127,7 @@ test.describe('AutoSave Fields Step', () => { await triggerAutosave(page); - await page.getByRole('combobox').click(); + await page.getByRole('combobox').first().click(); await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click(); await page.getByRole('button', { name: 'Signature' }).click(); @@ -140,7 +140,7 @@ test.describe('AutoSave Fields Step', () => { await triggerAutosave(page); - await page.getByRole('combobox').click(); + await page.getByRole('combobox').first().click(); await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click(); await page.getByText('Text').nth(1).click(); @@ -191,7 +191,7 @@ test.describe('AutoSave Fields Step', () => { await triggerAutosave(page); - await page.getByRole('combobox').click(); + await page.getByRole('combobox').first().click(); await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click(); await page.getByRole('button', { name: 'Signature' }).click(); @@ -204,7 +204,7 @@ test.describe('AutoSave Fields Step', () => { await triggerAutosave(page); - await page.getByRole('combobox').click(); + await page.getByRole('combobox').first().click(); await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click(); await page.getByText('Signature').nth(1).click(); diff --git a/packages/app-tests/e2e/document-flow/autosave-settings-step.spec.ts b/packages/app-tests/e2e/document-flow/autosave-settings-step.spec.ts index e34f2c104..bd999b79b 100644 --- a/packages/app-tests/e2e/document-flow/autosave-settings-step.spec.ts +++ b/packages/app-tests/e2e/document-flow/autosave-settings-step.spec.ts @@ -24,7 +24,7 @@ const setupDocument = async (page: Page) => { }; const triggerAutosave = async (page: Page) => { - await page.locator('#document-flow-form-container').click(); + await page.locator('body').click({ position: { x: 0, y: 0 } }); await page.locator('#document-flow-form-container').blur(); await page.waitForTimeout(5000); diff --git a/packages/app-tests/e2e/document-flow/autosave-signers-step.spec.ts b/packages/app-tests/e2e/document-flow/autosave-signers-step.spec.ts index e4d255750..49836bfb7 100644 --- a/packages/app-tests/e2e/document-flow/autosave-signers-step.spec.ts +++ b/packages/app-tests/e2e/document-flow/autosave-signers-step.spec.ts @@ -26,7 +26,7 @@ const setupDocumentAndNavigateToSignersStep = async (page: Page) => { }; const triggerAutosave = async (page: Page) => { - await page.locator('#document-flow-form-container').click(); + await page.locator('body').click({ position: { x: 0, y: 0 } }); await page.locator('#document-flow-form-container').blur(); await page.waitForTimeout(5000); @@ -92,7 +92,7 @@ test.describe('AutoSave Signers Step', () => { await triggerAutosave(page); - await page.getByRole('combobox').click(); + await page.getByRole('combobox').first().click(); await page.getByRole('option', { name: 'Receives copy' }).click(); await triggerAutosave(page); @@ -160,9 +160,20 @@ test.describe('AutoSave Signers Step', () => { expect(retrievedDocumentData.documentMeta?.signingOrder).toBe('SEQUENTIAL'); expect(retrievedDocumentData.documentMeta?.allowDictateNextSigner).toBe(true); expect(retrievedRecipients.length).toBe(3); - expect(retrievedRecipients[0].signingOrder).toBe(2); - expect(retrievedRecipients[1].signingOrder).toBe(3); - expect(retrievedRecipients[2].signingOrder).toBe(1); + + const firstRecipient = retrievedRecipients.find( + (r) => r.email === 'recipient1@documenso.com', + ); + const secondRecipient = retrievedRecipients.find( + (r) => r.email === 'recipient2@documenso.com', + ); + const thirdRecipient = retrievedRecipients.find( + (r) => r.email === 'recipient3@documenso.com', + ); + + expect(firstRecipient?.signingOrder).toBe(2); + expect(secondRecipient?.signingOrder).toBe(3); + expect(thirdRecipient?.signingOrder).toBe(1); }).toPass(); }); }); diff --git a/packages/app-tests/e2e/document-flow/autosave-subject-step.spec.ts b/packages/app-tests/e2e/document-flow/autosave-subject-step.spec.ts index 270a31d8e..3ba3ee5d9 100644 --- a/packages/app-tests/e2e/document-flow/autosave-subject-step.spec.ts +++ b/packages/app-tests/e2e/document-flow/autosave-subject-step.spec.ts @@ -42,7 +42,7 @@ export const setupDocumentAndNavigateToSubjectStep = async (page: Page) => { }; export const triggerAutosave = async (page: Page) => { - await page.locator('#document-flow-form-container').click(); + await page.locator('body').click({ position: { x: 0, y: 0 } }); await page.locator('#document-flow-form-container').blur(); await page.waitForTimeout(5000); diff --git a/packages/app-tests/e2e/document-flow/duplicate-recipients-simple.spec.ts b/packages/app-tests/e2e/document-flow/duplicate-recipients-simple.spec.ts new file mode 100644 index 000000000..02674af1d --- /dev/null +++ b/packages/app-tests/e2e/document-flow/duplicate-recipients-simple.spec.ts @@ -0,0 +1,56 @@ +import { expect, test } from '@playwright/test'; + +import { seedBlankDocument } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; + +test('[DOCUMENT_FLOW]: Simple duplicate recipients test', async ({ page }) => { + const { user, team } = await seedUser(); + const document = await seedBlankDocument(user, team.id); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents/${document.id}/edit`, + }); + + // Step 1: Settings - Continue with defaults + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); + + // Step 2: Add duplicate recipients + await page.getByPlaceholder('Email').fill('duplicate@example.com'); + await page.getByPlaceholder('Name').fill('Duplicate 1'); + + await page.getByRole('button', { name: 'Add Signer' }).click(); + await page.getByLabel('Email').nth(1).fill('duplicate@example.com'); + await page.getByLabel('Name').nth(1).fill('Duplicate 2'); + + // Continue to fields + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); + + // Step 3: Add fields + await page.getByRole('button', { name: 'Signature' }).click(); + await page.locator('canvas').click({ position: { x: 100, y: 100 } }); + + await page.getByRole('combobox').first().click(); + + // Switch to second duplicate and add field + await page.getByText('Duplicate 2 (duplicate@example.com)').first().click(); + await page.getByRole('button', { name: 'Signature' }).click(); + await page.locator('canvas').click({ position: { x: 200, y: 100 } }); + + // Continue to send + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible(); + + // Send document + await page.waitForTimeout(2500); + await page.getByRole('button', { name: 'Send' }).click(); + + await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`)); + + await expect(page.getByRole('link', { name: document.title })).toBeVisible(); +}); diff --git a/packages/app-tests/e2e/document-flow/duplicate-recipients.spec.ts b/packages/app-tests/e2e/document-flow/duplicate-recipients.spec.ts new file mode 100644 index 000000000..0f23d6ac7 --- /dev/null +++ b/packages/app-tests/e2e/document-flow/duplicate-recipients.spec.ts @@ -0,0 +1,355 @@ +import { type Page, expect, test } from '@playwright/test'; +import type { Document, Team } from '@prisma/client'; + +import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { prisma } from '@documenso/prisma'; +import { seedBlankDocument } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; +import { signSignaturePad } from '../fixtures/signature'; + +/** + * Test helper to complete the document creation flow with duplicate recipients + */ +const completeDocumentFlowWithDuplicateRecipients = async (options: { + page: Page; + team: Team; + document: Document; +}) => { + const { page, team, document } = options; + + // Step 1: Settings - Continue with defaults + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); + + // Step 2: Add duplicate recipients + await page.getByPlaceholder('Email').fill('duplicate@example.com'); + await page.getByPlaceholder('Name').fill('Duplicate Recipient 1'); + + // Add second signer with same email + await page.getByRole('button', { name: 'Add Signer' }).click(); + await page.getByLabel('Email').nth(1).fill('duplicate@example.com'); + await page.getByLabel('Name').nth(1).fill('Duplicate Recipient 2'); + + // Add third signer with different email for comparison + await page.getByRole('button', { name: 'Add Signer' }).click(); + await page.getByLabel('Email').nth(2).fill('unique@example.com'); + await page.getByLabel('Name').nth(2).fill('Unique Recipient'); + + // Continue to fields + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); + + // Step 3: Add fields for each recipient + // Add signature field for first duplicate recipient + await page.getByRole('button', { name: 'Signature' }).click(); + await page.locator('canvas').click({ position: { x: 100, y: 100 } }); + + await page.getByText('Duplicate Recipient 1 (duplicate@example.com)').click(); + + // Switch to second duplicate recipient and add their field + await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click(); + await page.getByRole('button', { name: 'Signature' }).click(); + await page.locator('canvas').click({ position: { x: 200, y: 100 } }); + + await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click(); + + // Switch to unique recipient and add their field + await page.getByText('Unique Recipient (unique@example.com)').click(); + await page.getByRole('button', { name: 'Signature' }).click(); + await page.locator('canvas').click({ position: { x: 300, y: 100 } }); + + // Continue to subject + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible(); + + // Step 4: Complete with subject and send + await page.waitForTimeout(2500); + await page.getByRole('button', { name: 'Send' }).click(); + + // Wait for send confirmation + await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`)); + + await expect(page.getByRole('link', { name: document.title })).toBeVisible(); +}; + +test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => { + test('should allow creating document with duplicate recipient emails', async ({ page }) => { + const { user, team } = await seedUser(); + + const document = await seedBlankDocument(user, team.id); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents/${document.id}/edit`, + }); + + // Complete the flow + await completeDocumentFlowWithDuplicateRecipients({ + page, + team, + document, + }); + + // Verify document was created successfully + await expect(page).toHaveURL(new RegExp(`/t/${team.url}/documents`)); + }); + + test('should allow adding duplicate recipient after saving document initially', async ({ + page, + }) => { + const { user, team } = await seedUser(); + const document = await seedBlankDocument(user, team.id); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents/${document.id}/edit`, + }); + + // Step 1: Settings - Continue with defaults + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); + + // Step 2: Add initial recipient + await page.getByPlaceholder('Email').fill('test@example.com'); + await page.getByPlaceholder('Name').fill('Test Recipient'); + + // Continue to fields and add a field + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); + + await page.getByRole('button', { name: 'Signature' }).click(); + await page.locator('canvas').click({ position: { x: 100, y: 100 } }); + + // Save the document by going to subject + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible(); + + // Navigate back to signers to add duplicate + await page.getByRole('button', { name: 'Go Back' }).click(); + await page.getByRole('button', { name: 'Go Back' }).click(); + await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); + + // Add duplicate recipient + await page.getByRole('button', { name: 'Add Signer' }).click(); + await page.getByLabel('Email').nth(1).fill('test@example.com'); + await page.getByLabel('Name').nth(1).fill('Test Recipient Duplicate'); + + // Continue and add field for duplicate + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); + + await page.waitForTimeout(1000); + + // Switch to duplicate recipient and add field + await page.getByRole('combobox').first().click(); + await page.getByText('Test Recipient Duplicate (test@example.com)').first().click(); + + await page.getByRole('button', { name: 'Signature' }).click(); + await page.locator('canvas').click({ position: { x: 200, y: 100 } }); + + // Complete the flow + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible(); + await page.waitForTimeout(2500); + await page.getByRole('button', { name: 'Send' }).click(); + + await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`)); + + await expect(page.getByRole('link', { name: document.title })).toBeVisible(); + }); + + test('should isolate fields per recipient token even with duplicate emails', async ({ + page, + context, + }) => { + const { user, team } = await seedUser(); + + const document = await seedBlankDocument(user, team.id); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents/${document.id}/edit`, + }); + + // Complete the document flow + await completeDocumentFlowWithDuplicateRecipients({ + page, + team, + document, + }); + + // Navigate to documents list and get the document + await expect(page).toHaveURL(new RegExp(`/t/${team.url}/documents`)); + + const recipients = await prisma.recipient.findMany({ + where: { + documentId: document.id, + }, + }); + + expect(recipients).toHaveLength(3); + + const tokens = recipients.map((r) => r.token); + + expect(new Set(tokens).size).toBe(3); // All tokens should be unique + + // Test each signing experience in separate browser contexts + for (const recipient of recipients) { + // Navigate to signing URL + await page.goto(`/sign/${recipient.token}`, { + waitUntil: 'networkidle', + }); + + await page.waitForSelector(PDF_VIEWER_PAGE_SELECTOR); + + // Verify only one signature field is visible for this recipient + expect( + await page.locator(`[data-field-type="SIGNATURE"]:not([data-readonly="true"])`).all(), + ).toHaveLength(1); + + // Verify recipient name is correct + await expect(page.getByLabel('Full Name')).toHaveValue(recipient.name); + + // Sign the document + await signSignaturePad(page); + + await page + .locator('[data-field-type="SIGNATURE"]:not([data-readonly="true"])') + .first() + .click(); + + await page.getByRole('button', { name: 'Complete' }).click(); + await page.getByRole('button', { name: 'Sign' }).click(); + + // Verify completion + await page.waitForURL(`/sign/${recipient?.token}/complete`); + await expect(page.getByText('Document Signed')).toBeVisible(); + } + }); + + test('should handle duplicate recipient workflow with different field types', async ({ + page, + }) => { + const { user, team } = await seedUser(); + const document = await seedBlankDocument(user, team.id); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents/${document.id}/edit`, + }); + + // Step 1: Settings + await page.getByRole('button', { name: 'Continue' }).click(); + + // Step 2: Add duplicate recipients with different roles + await page.getByPlaceholder('Email').fill('signer@example.com'); + await page.getByPlaceholder('Name').fill('Signer Role'); + + await page.getByRole('button', { name: 'Add Signer' }).click(); + await page.getByLabel('Email').nth(1).fill('signer@example.com'); + await page.getByLabel('Name').nth(1).fill('Approver Role'); + + // Change second recipient role if role selector is available + const roleDropdown = page.getByLabel('Role').nth(1); + + if (await roleDropdown.isVisible()) { + await roleDropdown.click(); + await page.getByText('Approver').click(); + } + + // Step 3: Add different field types for each duplicate + await page.getByRole('button', { name: 'Continue' }).click(); + + // Add signature for first recipient + await page.getByRole('button', { name: 'Signature' }).click(); + await page.locator('canvas').click({ position: { x: 100, y: 100 } }); + + // Add name field for second recipient + await page.getByRole('combobox').first().click(); + + await page.getByText('Approver Role (signer@example.com)').first().click(); + await page.getByRole('button', { name: 'Name' }).click(); + await page.locator('canvas').click({ position: { x: 200, y: 100 } }); + + // Add date field for second recipient + await page.getByRole('button', { name: 'Date' }).click(); + await page.locator('canvas').click({ position: { x: 200, y: 150 } }); + + // Complete the document + await page.getByRole('button', { name: 'Continue' }).click(); + + await page.getByRole('button', { name: 'Send' }).click(); + + await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`)); + + await expect(page.getByRole('link', { name: document.title })).toBeVisible(); + }); + + test('should preserve field assignments when editing document with duplicates', async ({ + page, + }) => { + const { user, team } = await seedUser(); + const document = await seedBlankDocument(user, team.id); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents/${document.id}/edit`, + }); + + // Create document with duplicates and fields + await completeDocumentFlowWithDuplicateRecipients({ + page, + team, + document, + }); + + // Navigate back to edit the document + await page.goto(`/t/${team.url}/documents/${document.id}/edit`); + + // Go to fields step + await page.getByRole('button', { name: 'Continue' }).click(); // Settings + await page.getByRole('button', { name: 'Continue' }).click(); // Signers + + // Verify fields are assigned to correct recipients + await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); + + // Click on first duplicate recipient + await page.getByText('Duplicate Recipient 1 (duplicate@example.com)').click(); + + // Verify their field is visible and can be selected + const firstRecipientFields = await page + .locator(`[data-field-type="SIGNATURE"]:not(:disabled)`) + .all(); + expect(firstRecipientFields.length).toBeGreaterThan(0); + + // Switch to second duplicate recipient + await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click(); + + // Verify they have their own field + const secondRecipientFields = await page + .locator(`[data-field-type="SIGNATURE"]:not(:disabled)`) + .all(); + expect(secondRecipientFields.length).toBeGreaterThan(0); + + // Add another field to the second duplicate + await page.getByRole('button', { name: 'Name' }).click(); + await page.locator('canvas').click({ position: { x: 250, y: 150 } }); + + // Save changes + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible(); + await page.waitForTimeout(2500); + await page.getByRole('button', { name: 'Send' }).click(); + + await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`)); + + await expect(page.getByRole('link', { name: document.title })).toBeVisible(); + }); +}); diff --git a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts index 1e1a70288..06251175e 100644 --- a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts +++ b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts @@ -573,6 +573,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip y: 100 * i, }, }); + await page.getByText(`User ${i} (user${i}@example.com)`).click(); } diff --git a/packages/app-tests/e2e/folders/team-account-folders.spec.ts b/packages/app-tests/e2e/folders/team-account-folders.spec.ts index 71649be8d..281848adb 100644 --- a/packages/app-tests/e2e/folders/team-account-folders.spec.ts +++ b/packages/app-tests/e2e/folders/team-account-folders.spec.ts @@ -277,13 +277,13 @@ test('[TEAMS]: document folder and its contents can be deleted', async ({ page } await page.goto(`/t/${team.url}/documents`); - await expect(page.locator('div').filter({ hasText: folder.name })).not.toBeVisible(); + await expect(page.locator(`[data-folder-id="${folder.id}"]`)).not.toBeVisible(); await expect(page.getByText(proposal.title)).not.toBeVisible(); await page.goto(`/t/${team.url}/documents/f/${folder.id}`); await expect(page.getByText(report.title)).not.toBeVisible(); - await expect(page.locator('div').filter({ hasText: reportsFolder.name })).not.toBeVisible(); + await expect(page.locator(`[data-folder-id="${reportsFolder.id}"]`)).not.toBeVisible(); }); test('[TEAMS]: create folder button is visible on templates page', async ({ page }) => { @@ -318,9 +318,7 @@ test('[TEAMS]: can create a template folder', async ({ page }) => { await expect(page.getByText('Team template folder')).toBeVisible(); await page.goto(`/t/${team.url}/templates`); - await expect( - page.locator('div').filter({ hasText: 'Team template folder' }).nth(3), - ).toBeVisible(); + await expect(page.locator(`[data-folder-name="Team template folder"]`)).toBeVisible(); }); test('[TEAMS]: can create a template subfolder inside a template folder', async ({ page }) => { @@ -374,11 +372,8 @@ test('[TEAMS]: can create a template inside a template folder', async ({ page }) await page.getByRole('button', { name: 'New Template' }).click(); - await page - .locator('div') - .filter({ hasText: /^Upload Template DocumentDrag & drop your PDF here\.$/ }) - .nth(2) - .click(); + await page.getByText('Upload Template Document').click(); + await page.locator('input[type="file"]').nth(0).waitFor({ state: 'attached' }); await page @@ -537,7 +532,7 @@ test('[TEAMS]: template folder can be moved to another template folder', async ( await expect(page.getByText('Team Contract Templates')).toBeVisible(); }); -test('[TEAMS]: template folder and its contents can be deleted', async ({ page }) => { +test('[TEAMS]: template folder can be deleted', async ({ page }) => { const { team, teamOwner } = await seedTeamDocuments(); const folder = await seedBlankFolder(teamOwner, team.id, { @@ -585,13 +580,16 @@ test('[TEAMS]: template folder and its contents can be deleted', async ({ page } await page.goto(`/t/${team.url}/templates`); - await expect(page.locator('div').filter({ hasText: folder.name })).not.toBeVisible(); - await expect(page.getByText(template.title)).not.toBeVisible(); + await page.waitForTimeout(1000); + + // !: This is no longer the case, when deleting a folder its contents will be moved to the root folder. + // await expect(page.locator(`[data-folder-id="${folder.id}"]`)).not.toBeVisible(); + // await expect(page.getByText(template.title)).not.toBeVisible(); await page.goto(`/t/${team.url}/templates/f/${folder.id}`); await expect(page.getByText(reportTemplate.title)).not.toBeVisible(); - await expect(page.locator('div').filter({ hasText: subfolder.name })).not.toBeVisible(); + await expect(page.locator(`[data-folder-id="${subfolder.id}"]`)).not.toBeVisible(); }); test('[TEAMS]: can navigate between template folders', async ({ page }) => { @@ -843,10 +841,15 @@ test('[TEAMS]: documents inherit folder visibility', async ({ page }) => { await page.getByText('Admin Only Folder').click(); - const fileInput = page.locator('input[type="file"]').nth(1); - await fileInput.waitFor({ state: 'attached' }); + await page.waitForURL(new RegExp(`/t/${team.url}/documents/f/.+`)); - await fileInput.setInputFiles( + // Upload document. + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + page.getByRole('button', { name: 'Upload Document' }).click(), + ]); + + await fileChooser.setFiles( path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'), ); diff --git a/packages/app-tests/e2e/templates-flow/duplicate-recipients.spec.ts b/packages/app-tests/e2e/templates-flow/duplicate-recipients.spec.ts new file mode 100644 index 000000000..98056ab82 --- /dev/null +++ b/packages/app-tests/e2e/templates-flow/duplicate-recipients.spec.ts @@ -0,0 +1,283 @@ +import { type Page, expect, test } from '@playwright/test'; +import type { Team, Template } from '@prisma/client'; + +import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { prisma } from '@documenso/prisma'; +import { seedBlankTemplate } from '@documenso/prisma/seed/templates'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; + +/** + * Test helper to complete template creation with duplicate recipients + */ +const completeTemplateFlowWithDuplicateRecipients = async (options: { + page: Page; + team: Team; + template: Template; +}) => { + const { page, team, template } = options; + // Step 1: Settings - Continue with defaults + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible(); + + // Step 2: Add duplicate recipients with real emails for testing + await page.getByPlaceholder('Email').fill('duplicate@example.com'); + await page.getByPlaceholder('Name').fill('First Instance'); + + // Add second signer with same email + await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click(); + await page.getByPlaceholder('Email').nth(1).fill('duplicate@example.com'); + await page.getByPlaceholder('Name').nth(1).fill('Second Instance'); + + // Add third signer with different email + await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click(); + await page.getByPlaceholder('Email').nth(2).fill('unique@example.com'); + await page.getByPlaceholder('Name').nth(2).fill('Different Recipient'); + + // Continue to fields + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); + + // Step 3: Add fields for each recipient instance + // Add signature field for first instance + await page.getByRole('button', { name: 'Signature' }).click(); + await page.locator('canvas').click({ position: { x: 100, y: 100 } }); + + // Switch to second instance and add their field + await page.getByRole('combobox').first().click(); + await page.getByText('Second Instance').first().click(); + 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 + await page.getByRole('combobox').first().click(); + await page.getByText('Different Recipient').first().click(); + await page.getByRole('button', { name: 'Name' }).click(); + await page.locator('canvas').click({ position: { x: 300, y: 100 } }); + + // Save template + await page.getByRole('button', { name: 'Save Template' }).click(); + + // Wait for creation confirmation + await page.waitForURL(`/t/${team.url}/templates`); + await expect(page.getByRole('link', { name: template.title })).toBeVisible(); +}; + +test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => { + test('should allow creating template with duplicate recipient emails', async ({ page }) => { + const { user, team } = await seedUser(); + + const template = await seedBlankTemplate(user, team.id); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/templates/${template.id}/edit`, + }); + + // Complete the template flow + await completeTemplateFlowWithDuplicateRecipients({ page, team, template }); + + // Verify template was created successfully + await expect(page).toHaveURL(`/t/${team.url}/templates`); + }); + + test('should create document from template with duplicate recipients using same email', async ({ + page, + context, + }) => { + const { user, team } = await seedUser(); + + const template = await seedBlankTemplate(user, team.id); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/templates/${template.id}/edit`, + }); + + // Complete template creation + await completeTemplateFlowWithDuplicateRecipients({ page, team, template }); + + // Navigate to template and create document + await page.goto(`/t/${team.url}/templates`); + + await page + .getByRole('row', { name: template.title }) + .getByRole('button', { name: 'Use Template' }) + .click(); + + // Fill recipient information with same email for both instances + await expect(page.getByRole('heading', { name: 'Create document' })).toBeVisible(); + + // Set same email for both recipient instances + const emailInputs = await page.locator('[aria-label="Email"]').all(); + const nameInputs = await page.locator('[aria-label="Name"]').all(); + + // First instance + await emailInputs[0].fill('same@example.com'); + await nameInputs[0].fill('John Doe - Role 1'); + + // Second instance (same email) + await emailInputs[1].fill('same@example.com'); + await nameInputs[1].fill('John Doe - Role 2'); + + // Different recipient + await emailInputs[2].fill('different@example.com'); + await nameInputs[2].fill('Jane Smith'); + + await page.getByLabel('Send document').click(); + + // Create document + await page.getByRole('button', { name: 'Create and send' }).click(); + await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`)); + + // Get the document ID from URL for database queries + const url = page.url(); + const documentIdMatch = url.match(/\/documents\/(\d+)/); + + const documentId = documentIdMatch ? parseInt(documentIdMatch[1]) : null; + + expect(documentId).not.toBeNull(); + + // Get recipients directly from database + const recipients = await prisma.recipient.findMany({ + where: { + documentId: documentId!, + }, + }); + + expect(recipients).toHaveLength(3); + + // Verify all tokens are unique + const tokens = recipients.map((r) => r.token); + expect(new Set(tokens).size).toBe(3); + + // Test signing experience for duplicate email recipients + const duplicateRecipients = recipients.filter((r) => r.email === 'same@example.com'); + expect(duplicateRecipients).toHaveLength(2); + + for (const recipient of duplicateRecipients) { + // Navigate to signing URL + await page.goto(`/sign/${recipient.token}`, { + waitUntil: 'networkidle', + }); + + await page.waitForSelector(PDF_VIEWER_PAGE_SELECTOR); + + // Verify correct recipient name is shown + await expect(page.getByLabel('Full Name')).toHaveValue(recipient.name); + + // Verify only one signature field is visible for this recipient + expect( + await page.locator(`[data-field-type="SIGNATURE"]:not([data-readonly="true"])`).all(), + ).toHaveLength(1); + } + }); + + test('should handle template with different types of duplicate emails', async ({ page }) => { + const { user, team } = await seedUser(); + + const template = await seedBlankTemplate(user, team.id); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/templates/${template.id}/edit`, + }); + + // Step 1: Settings + await page.getByRole('button', { name: 'Continue' }).click(); + + // Step 2: Add multiple recipients with duplicate emails + await page.getByPlaceholder('Email').fill('duplicate@example.com'); + await page.getByPlaceholder('Name').fill('Duplicate Recipient 1'); + + await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click(); + await page.getByPlaceholder('Email').nth(1).fill('duplicate@example.com'); + await page.getByPlaceholder('Name').nth(1).fill('Duplicate Recipient 2'); + + await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click(); + await page.getByPlaceholder('Email').nth(2).fill('different@example.com'); + await page.getByPlaceholder('Name').nth(2).fill('Different Recipient'); + + // Continue and add fields + await page.getByRole('button', { name: 'Continue' }).click(); + + // Add fields for each recipient + await page.getByRole('button', { name: 'Signature' }).click(); + await page.locator('canvas').click({ position: { x: 100, y: 100 } }); + + await page.getByRole('combobox').first().click(); + await page.getByText('Duplicate Recipient 2').first().click(); + await page.getByRole('button', { name: 'Date' }).click(); + await page.locator('canvas').click({ position: { x: 200, y: 100 } }); + + await page.getByRole('combobox').first().click(); + await page.getByText('Different Recipient').first().click(); + await page.getByRole('button', { name: 'Name' }).click(); + await page.locator('canvas').click({ position: { x: 100, y: 200 } }); + + // Save template + await page.getByRole('button', { name: 'Save Template' }).click(); + + await page.waitForURL(`/t/${team.url}/templates`); + + await expect(page.getByRole('link', { name: template.title })).toBeVisible(); + }); + + test('should validate field assignments per recipient in template editing', async ({ page }) => { + const { user, team } = await seedUser(); + + const template = await seedBlankTemplate(user, team.id); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/templates/${template.id}/edit`, + }); + + // Create template with duplicates + await completeTemplateFlowWithDuplicateRecipients({ page, team, template }); + + // Navigate back to edit the template + await page.goto(`/t/${team.url}/templates/${template.id}/edit`); + + // Go to fields step + await page.getByRole('button', { name: 'Continue' }).click(); // Settings + await page.getByRole('button', { name: 'Continue' }).click(); // Signers + + await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); + + // Verify fields are correctly assigned to each recipient instance + await page.getByRole('combobox').first().click(); + await page.getByRole('option', { name: 'First Instance' }).first().click(); + let visibleFields = await page.locator(`[data-field-type="SIGNATURE"]:not(:disabled)`).all(); + expect(visibleFields.length).toBeGreaterThan(0); + + await page.getByRole('combobox').first().click(); + await page.getByRole('option', { name: 'Second Instance' }).first().click(); + visibleFields = await page.locator(`[data-field-type="SIGNATURE"]:not(:disabled)`).all(); + expect(visibleFields.length).toBeGreaterThan(0); + + await page.getByRole('combobox').first().click(); + await page.getByRole('option', { name: 'Different Recipient' }).first().click(); + const nameFields = await page.locator(`[data-field-type="NAME"]:not(:disabled)`).all(); + expect(nameFields.length).toBeGreaterThan(0); + + // Add additional field to verify proper assignment + await page.getByRole('combobox').first().click(); + await page.getByRole('option', { name: 'First Instance' }).first().click(); + await page.getByRole('button', { name: 'Name' }).click(); + await page.locator('canvas').click({ position: { x: 100, y: 300 } }); + + await page.waitForTimeout(2500); + + // Save changes + await page.getByRole('button', { name: 'Save Template' }).click(); + + await page.waitForURL(`/t/${team.url}/templates`); + await expect(page.getByRole('link', { name: template.title })).toBeVisible(); + }); +}); diff --git a/packages/app-tests/e2e/templates-flow/template-autosave-fields-step.spec.ts b/packages/app-tests/e2e/templates-flow/template-autosave-fields-step.spec.ts index 5a167340a..0680d3e40 100644 --- a/packages/app-tests/e2e/templates-flow/template-autosave-fields-step.spec.ts +++ b/packages/app-tests/e2e/templates-flow/template-autosave-fields-step.spec.ts @@ -33,7 +33,7 @@ const setupTemplateAndNavigateToFieldsStep = async (page: Page) => { }; const triggerAutosave = async (page: Page) => { - await page.locator('#document-flow-form-container').click(); + await page.locator('body').click({ position: { x: 0, y: 0 } }); await page.locator('#document-flow-form-container').blur(); await page.waitForTimeout(5000); @@ -70,7 +70,7 @@ test.describe('AutoSave Fields Step', () => { await triggerAutosave(page); - await page.getByRole('combobox').click(); + await page.getByRole('combobox').first().click(); await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click(); await page.getByRole('button', { name: 'Signature' }).click(); @@ -129,7 +129,7 @@ test.describe('AutoSave Fields Step', () => { await triggerAutosave(page); - await page.getByRole('combobox').click(); + await page.getByRole('combobox').first().click(); await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click(); await page.getByRole('button', { name: 'Signature' }).click(); @@ -142,7 +142,7 @@ test.describe('AutoSave Fields Step', () => { await triggerAutosave(page); - await page.getByRole('combobox').click(); + await page.getByRole('combobox').first().click(); await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click(); await page.getByText('Text').nth(1).click(); @@ -195,7 +195,7 @@ test.describe('AutoSave Fields Step', () => { await triggerAutosave(page); - await page.getByRole('combobox').click(); + await page.getByRole('combobox').first().click(); await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click(); await page.getByRole('button', { name: 'Signature' }).click(); @@ -208,7 +208,7 @@ test.describe('AutoSave Fields Step', () => { await triggerAutosave(page); - await page.getByRole('combobox').click(); + await page.getByRole('combobox').first().click(); await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click(); await page.getByText('Signature').nth(1).click(); diff --git a/packages/app-tests/e2e/templates-flow/template-autosave-settings-step.spec.ts b/packages/app-tests/e2e/templates-flow/template-autosave-settings-step.spec.ts index af12e7290..620fb93db 100644 --- a/packages/app-tests/e2e/templates-flow/template-autosave-settings-step.spec.ts +++ b/packages/app-tests/e2e/templates-flow/template-autosave-settings-step.spec.ts @@ -23,7 +23,7 @@ const setupTemplate = async (page: Page) => { }; const triggerAutosave = async (page: Page) => { - await page.locator('#document-flow-form-container').click(); + await page.locator('body').click({ position: { x: 0, y: 0 } }); await page.locator('#document-flow-form-container').blur(); await page.waitForTimeout(5000); diff --git a/packages/app-tests/e2e/templates-flow/template-autosave-signers-step.spec.ts b/packages/app-tests/e2e/templates-flow/template-autosave-signers-step.spec.ts index f5bd07e94..943b6834f 100644 --- a/packages/app-tests/e2e/templates-flow/template-autosave-signers-step.spec.ts +++ b/packages/app-tests/e2e/templates-flow/template-autosave-signers-step.spec.ts @@ -26,7 +26,7 @@ const setupTemplateAndNavigateToSignersStep = async (page: Page) => { }; const triggerAutosave = async (page: Page) => { - await page.locator('#document-flow-form-container').click(); + await page.locator('body').click({ position: { x: 0, y: 0 } }); await page.locator('#document-flow-form-container').blur(); await page.waitForTimeout(5000); diff --git a/packages/app-tests/playwright.config.ts b/packages/app-tests/playwright.config.ts index 3536e340d..4373c7b93 100644 --- a/packages/app-tests/playwright.config.ts +++ b/packages/app-tests/playwright.config.ts @@ -17,7 +17,7 @@ export default defineConfig({ testDir: './e2e', /* Run tests in files in parallel */ fullyParallel: false, - workers: 4, + workers: 2, maxFailures: process.env.CI ? 1 : undefined, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, @@ -33,7 +33,7 @@ export default defineConfig({ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on', - video: 'retain-on-failure', + video: 'on-first-retry', /* Add explicit timeouts for actions */ actionTimeout: 15_000, diff --git a/packages/lib/client-only/hooks/use-autosave.ts b/packages/lib/client-only/hooks/use-autosave.ts index 5c9b3db62..025c285d5 100644 --- a/packages/lib/client-only/hooks/use-autosave.ts +++ b/packages/lib/client-only/hooks/use-autosave.ts @@ -1,23 +1,56 @@ import { useCallback, useEffect, useRef } from 'react'; -export const useAutoSave = (onSave: (data: T) => Promise) => { - const saveTimeoutRef = useRef(); +type SaveRequest = { + data: T; + onResponse?: (response: R) => void; +}; - const saveFormData = async (data: T) => { - try { - await onSave(data); - } catch (error) { - console.error('Auto-save failed:', error); +export const useAutoSave = ( + onSave: (data: T) => Promise, + options: { delay?: number } = {}, +) => { + const { delay = 2000 } = options; + + const saveTimeoutRef = useRef(); + const saveQueueRef = useRef[]>([]); + const isProcessingRef = useRef(false); + + const processQueue = async () => { + if (isProcessingRef.current || saveQueueRef.current.length === 0) { + return; } + + isProcessingRef.current = true; + + while (saveQueueRef.current.length > 0) { + const request = saveQueueRef.current.shift()!; + + try { + const response = await onSave(request.data); + request.onResponse?.(response); + } catch (error) { + console.error('Auto-save failed:', error); + } + } + + isProcessingRef.current = false; }; - const scheduleSave = useCallback((data: T) => { - if (saveTimeoutRef.current) { - clearTimeout(saveTimeoutRef.current); - } + const saveFormData = async (data: T, onResponse?: (response: R) => void) => { + saveQueueRef.current.push({ data, onResponse }); + await processQueue(); + }; - saveTimeoutRef.current = setTimeout(() => void saveFormData(data), 2000); - }, []); + const scheduleSave = useCallback( + (data: T, onResponse?: (response: R) => void) => { + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + + saveTimeoutRef.current = setTimeout(() => void saveFormData(data, onResponse), delay); + }, + [delay], + ); useEffect(() => { return () => { diff --git a/packages/lib/server-only/field/set-fields-for-document.ts b/packages/lib/server-only/field/set-fields-for-document.ts index 7ec75796a..497f01078 100644 --- a/packages/lib/server-only/field/set-fields-for-document.ts +++ b/packages/lib/server-only/field/set-fields-for-document.ts @@ -84,9 +84,7 @@ export const setFieldsForDocument = async ({ const linkedFields = fields.map((field) => { const existing = existingFields.find((existingField) => existingField.id === field.id); - const recipient = document.recipients.find( - (recipient) => recipient.email.toLowerCase() === field.signerEmail.toLowerCase(), - ); + const recipient = document.recipients.find((recipient) => recipient.id === field.recipientId); // Each field MUST have a recipient associated with it. if (!recipient) { @@ -226,10 +224,8 @@ export const setFieldsForDocument = async ({ }, recipient: { connect: { - documentId_email: { - documentId, - email: fieldSignerEmail, - }, + id: field.recipientId, + documentId, }, }, }, @@ -330,6 +326,7 @@ type FieldData = { id?: number | null; type: FieldType; signerEmail: string; + recipientId: number; pageNumber: number; pageX: number; pageY: number; diff --git a/packages/lib/server-only/field/set-fields-for-template.ts b/packages/lib/server-only/field/set-fields-for-template.ts index 285e26e69..a116c6275 100644 --- a/packages/lib/server-only/field/set-fields-for-template.ts +++ b/packages/lib/server-only/field/set-fields-for-template.ts @@ -26,6 +26,7 @@ export type SetFieldsForTemplateOptions = { id?: number | null; type: FieldType; signerEmail: string; + recipientId: number; pageNumber: number; pageX: number; pageY: number; @@ -169,10 +170,8 @@ export const setFieldsForTemplate = async ({ }, recipient: { connect: { - templateId_email: { - templateId, - email: field.signerEmail.toLowerCase(), - }, + id: field.recipientId, + templateId, }, }, }, diff --git a/packages/lib/server-only/recipient/create-document-recipients.ts b/packages/lib/server-only/recipient/create-document-recipients.ts index cf584a874..4baac9f1c 100644 --- a/packages/lib/server-only/recipient/create-document-recipients.ts +++ b/packages/lib/server-only/recipient/create-document-recipients.ts @@ -85,20 +85,6 @@ export const createDocumentRecipients = async ({ email: recipient.email.toLowerCase(), })); - const duplicateRecipients = normalizedRecipients.filter((newRecipient) => { - const existingRecipient = document.recipients.find( - (existingRecipient) => existingRecipient.email === newRecipient.email, - ); - - return existingRecipient !== undefined; - }); - - if (duplicateRecipients.length > 0) { - throw new AppError(AppErrorCode.INVALID_REQUEST, { - message: `Duplicate recipient(s) found for ${duplicateRecipients.map((recipient) => recipient.email).join(', ')}`, - }); - } - const createdRecipients = await prisma.$transaction(async (tx) => { return await Promise.all( normalizedRecipients.map(async (recipient) => { diff --git a/packages/lib/server-only/recipient/create-template-recipients.ts b/packages/lib/server-only/recipient/create-template-recipients.ts index 34d61ef66..1621e87b7 100644 --- a/packages/lib/server-only/recipient/create-template-recipients.ts +++ b/packages/lib/server-only/recipient/create-template-recipients.ts @@ -71,20 +71,6 @@ export const createTemplateRecipients = async ({ email: recipient.email.toLowerCase(), })); - const duplicateRecipients = normalizedRecipients.filter((newRecipient) => { - const existingRecipient = template.recipients.find( - (existingRecipient) => existingRecipient.email === newRecipient.email, - ); - - return existingRecipient !== undefined; - }); - - if (duplicateRecipients.length > 0) { - throw new AppError(AppErrorCode.INVALID_REQUEST, { - message: `Duplicate recipient(s) found for ${duplicateRecipients.map((recipient) => recipient.email).join(', ')}`, - }); - } - const createdRecipients = await prisma.$transaction(async (tx) => { return await Promise.all( normalizedRecipients.map(async (recipient) => { diff --git a/packages/lib/server-only/recipient/set-document-recipients.ts b/packages/lib/server-only/recipient/set-document-recipients.ts index bfdbca552..fd9ba5730 100644 --- a/packages/lib/server-only/recipient/set-document-recipients.ts +++ b/packages/lib/server-only/recipient/set-document-recipients.ts @@ -122,16 +122,12 @@ export const setDocumentRecipients = async ({ const removedRecipients = existingRecipients.filter( (existingRecipient) => - !normalizedRecipients.find( - (recipient) => - recipient.id === existingRecipient.id || recipient.email === existingRecipient.email, - ), + !normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id), ); const linkedRecipients = normalizedRecipients.map((recipient) => { const existing = existingRecipients.find( - (existingRecipient) => - existingRecipient.id === recipient.id || existingRecipient.email === recipient.email, + (existingRecipient) => existingRecipient.id === recipient.id, ); const canPersistedRecipientBeModified = diff --git a/packages/lib/server-only/recipient/set-template-recipients.ts b/packages/lib/server-only/recipient/set-template-recipients.ts index f09968585..955ff94f1 100644 --- a/packages/lib/server-only/recipient/set-template-recipients.ts +++ b/packages/lib/server-only/recipient/set-template-recipients.ts @@ -94,10 +94,7 @@ export const setTemplateRecipients = async ({ const removedRecipients = existingRecipients.filter( (existingRecipient) => - !normalizedRecipients.find( - (recipient) => - recipient.id === existingRecipient.id || recipient.email === existingRecipient.email, - ), + !normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id), ); if (template.directLink !== null) { @@ -124,8 +121,7 @@ export const setTemplateRecipients = async ({ const linkedRecipients = normalizedRecipients.map((recipient) => { const existing = existingRecipients.find( - (existingRecipient) => - existingRecipient.id === recipient.id || existingRecipient.email === recipient.email, + (existingRecipient) => existingRecipient.id === recipient.id, ); return { diff --git a/packages/lib/server-only/recipient/update-document-recipients.ts b/packages/lib/server-only/recipient/update-document-recipients.ts index 639c968f1..6372f56a2 100644 --- a/packages/lib/server-only/recipient/update-document-recipients.ts +++ b/packages/lib/server-only/recipient/update-document-recipients.ts @@ -91,17 +91,6 @@ export const updateDocumentRecipients = async ({ }); } - const duplicateRecipientWithSameEmail = document.recipients.find( - (existingRecipient) => - existingRecipient.email === recipient.email && existingRecipient.id !== recipient.id, - ); - - if (duplicateRecipientWithSameEmail) { - throw new AppError(AppErrorCode.INVALID_REQUEST, { - message: `Duplicate recipient with the same email found: ${duplicateRecipientWithSameEmail.email}`, - }); - } - if (!canRecipientBeModified(originalRecipient, document.fields)) { throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Cannot modify a recipient who has already interacted with the document', diff --git a/packages/lib/server-only/recipient/update-template-recipients.ts b/packages/lib/server-only/recipient/update-template-recipients.ts index 5867e85ab..aca4520f8 100644 --- a/packages/lib/server-only/recipient/update-template-recipients.ts +++ b/packages/lib/server-only/recipient/update-template-recipients.ts @@ -80,17 +80,6 @@ export const updateTemplateRecipients = async ({ }); } - const duplicateRecipientWithSameEmail = template.recipients.find( - (existingRecipient) => - existingRecipient.email === recipient.email && existingRecipient.id !== recipient.id, - ); - - if (duplicateRecipientWithSameEmail) { - throw new AppError(AppErrorCode.INVALID_REQUEST, { - message: `Duplicate recipient with the same email found: ${duplicateRecipientWithSameEmail.email}`, - }); - } - return { originalRecipient, recipientUpdateData: recipient, diff --git a/packages/lib/server-only/template/create-document-from-template-legacy.ts b/packages/lib/server-only/template/create-document-from-template-legacy.ts index e0a20f55e..6bc83c667 100644 --- a/packages/lib/server-only/template/create-document-from-template-legacy.ts +++ b/packages/lib/server-only/template/create-document-from-template-legacy.ts @@ -19,6 +19,8 @@ export type CreateDocumentFromTemplateLegacyOptions = { }[]; }; +// !TODO: Make this work + /** * Legacy server function for /api/v1 */ @@ -58,6 +60,15 @@ export const createDocumentFromTemplateLegacy = async ({ }, }); + const recipientsToCreate = template.recipients.map((recipient) => ({ + id: recipient.id, + email: recipient.email, + name: recipient.name, + role: recipient.role, + signingOrder: recipient.signingOrder, + token: nanoid(), + })); + const document = await prisma.document.create({ data: { qrToken: prefixedId('qr'), @@ -70,12 +81,12 @@ export const createDocumentFromTemplateLegacy = async ({ documentDataId: documentData.id, useLegacyFieldInsertion: template.useLegacyFieldInsertion ?? false, recipients: { - create: template.recipients.map((recipient) => ({ + create: recipientsToCreate.map((recipient) => ({ email: recipient.email, name: recipient.name, role: recipient.role, signingOrder: recipient.signingOrder, - token: nanoid(), + token: recipient.token, })), }, documentMeta: { @@ -95,9 +106,11 @@ export const createDocumentFromTemplateLegacy = async ({ await prisma.field.createMany({ data: template.fields.map((field) => { - const recipient = template.recipients.find((recipient) => recipient.id === field.recipientId); + const recipient = recipientsToCreate.find((recipient) => recipient.id === field.recipientId); - const documentRecipient = document.recipients.find((doc) => doc.email === recipient?.email); + const documentRecipient = document.recipients.find( + (documentRecipient) => documentRecipient.token === recipient?.token, + ); if (!documentRecipient) { throw new Error('Recipient not found.'); @@ -118,28 +131,32 @@ export const createDocumentFromTemplateLegacy = async ({ }), }); + // Replicate the old logic, get by index and create if we exceed the number of existing recipients. if (recipients && recipients.length > 0) { - document.recipients = await Promise.all( + await Promise.all( recipients.map(async (recipient, index) => { const existingRecipient = document.recipients.at(index); - return await prisma.recipient.upsert({ - where: { - documentId_email: { + if (existingRecipient) { + return await prisma.recipient.update({ + where: { + id: existingRecipient.id, documentId: document.id, - email: existingRecipient?.email ?? recipient.email, }, - }, - update: { - name: recipient.name, - email: recipient.email, - role: recipient.role, - signingOrder: recipient.signingOrder, - }, - create: { + data: { + name: recipient.name, + email: recipient.email, + role: recipient.role, + signingOrder: recipient.signingOrder, + }, + }); + } + + return await prisma.recipient.create({ + data: { documentId: document.id, - email: recipient.email, name: recipient.name, + email: recipient.email, role: recipient.role, signingOrder: recipient.signingOrder, token: nanoid(), @@ -149,5 +166,18 @@ export const createDocumentFromTemplateLegacy = async ({ ); } - return document; + // Gross but we need to do the additional fetch since we mutate above. + const updatedRecipients = await prisma.recipient.findMany({ + where: { + documentId: document.id, + }, + orderBy: { + id: 'asc', + }, + }); + + return { + ...document, + recipients: updatedRecipients, + }; }; diff --git a/packages/lib/server-only/template/create-document-from-template.ts b/packages/lib/server-only/template/create-document-from-template.ts index b6ee8b222..e7a1011c3 100644 --- a/packages/lib/server-only/template/create-document-from-template.ts +++ b/packages/lib/server-only/template/create-document-from-template.ts @@ -53,7 +53,7 @@ import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; type FinalRecipient = Pick< Recipient, - 'name' | 'email' | 'role' | 'authOptions' | 'signingOrder' + 'name' | 'email' | 'role' | 'authOptions' | 'signingOrder' | 'token' > & { templateRecipientId: number; fields: Field[]; @@ -350,6 +350,7 @@ export const createDocumentFromTemplate = async ({ role: templateRecipient.role, signingOrder: foundRecipient?.signingOrder ?? templateRecipient.signingOrder, authOptions: templateRecipient.authOptions, + token: nanoid(), }; }); @@ -441,7 +442,7 @@ export const createDocumentFromTemplate = async ({ ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED, signingOrder: recipient.signingOrder, - token: nanoid(), + token: recipient.token, }; }), }, @@ -500,8 +501,8 @@ export const createDocumentFromTemplate = async ({ } } - Object.values(finalRecipients).forEach(({ email, fields }) => { - const recipient = document.recipients.find((recipient) => recipient.email === email); + Object.values(finalRecipients).forEach(({ token, fields }) => { + const recipient = document.recipients.find((recipient) => recipient.token === token); if (!recipient) { throw new Error('Recipient not found.'); diff --git a/packages/prisma/migrations/20250917042725_remove_recipient_unique_email_constraints/migration.sql b/packages/prisma/migrations/20250917042725_remove_recipient_unique_email_constraints/migration.sql new file mode 100644 index 000000000..a6773703f --- /dev/null +++ b/packages/prisma/migrations/20250917042725_remove_recipient_unique_email_constraints/migration.sql @@ -0,0 +1,5 @@ +-- DropIndex +DROP INDEX "Recipient_documentId_email_key"; + +-- DropIndex +DROP INDEX "Recipient_templateId_email_key"; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 8337ab754..49e247cb7 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -527,8 +527,6 @@ model Recipient { fields Field[] signatures Signature[] - @@unique([documentId, email]) - @@unique([templateId, email]) @@index([documentId]) @@index([templateId]) @@index([token]) diff --git a/packages/trpc/server/document-router/create-document-temporary.types.ts b/packages/trpc/server/document-router/create-document-temporary.types.ts index 858b3835a..f7c15ee42 100644 --- a/packages/trpc/server/document-router/create-document-temporary.types.ts +++ b/packages/trpc/server/document-router/create-document-temporary.types.ts @@ -78,14 +78,7 @@ export const ZCreateDocumentTemporaryRequestSchema = z.object({ .optional(), }), ) - .refine( - (recipients) => { - const emails = recipients.map((recipient) => recipient.email); - return new Set(emails).size === emails.length; - }, - { message: 'Recipients must have unique emails' }, - ) .optional(), meta: z .object({ diff --git a/packages/trpc/server/embedding-router/create-embedding-document.types.ts b/packages/trpc/server/embedding-router/create-embedding-document.types.ts index 662136c17..40547d77b 100644 --- a/packages/trpc/server/embedding-router/create-embedding-document.types.ts +++ b/packages/trpc/server/embedding-router/create-embedding-document.types.ts @@ -47,14 +47,7 @@ export const ZCreateEmbeddingDocumentRequestSchema = z.object({ .optional(), }), ) - .refine( - (recipients) => { - const emails = recipients.map((recipient) => recipient.email); - return new Set(emails).size === emails.length; - }, - { message: 'Recipients must have unique emails' }, - ) .optional(), meta: z .object({ diff --git a/packages/trpc/server/embedding-router/update-embedding-document.types.ts b/packages/trpc/server/embedding-router/update-embedding-document.types.ts index 183ede703..c7521e91b 100644 --- a/packages/trpc/server/embedding-router/update-embedding-document.types.ts +++ b/packages/trpc/server/embedding-router/update-embedding-document.types.ts @@ -30,36 +30,27 @@ export const ZUpdateEmbeddingDocumentRequestSchema = z.object({ documentId: z.number(), title: ZDocumentTitleSchema, externalId: ZDocumentExternalIdSchema.optional(), - recipients: z - .array( - z.object({ - id: z.number().optional(), - email: z.string().toLowerCase().email().min(1), - name: z.string(), - role: z.nativeEnum(RecipientRole), - signingOrder: z.number().optional(), - fields: ZFieldAndMetaSchema.and( - z.object({ - id: z.number().optional(), - pageNumber: ZFieldPageNumberSchema, - pageX: ZFieldPageXSchema, - pageY: ZFieldPageYSchema, - width: ZFieldWidthSchema, - height: ZFieldHeightSchema, - }), - ) - .array() - .optional(), - }), - ) - .refine( - (recipients) => { - const emails = recipients.map((recipient) => recipient.email); - - return new Set(emails).size === emails.length; - }, - { message: 'Recipients must have unique emails' }, - ), + recipients: z.array( + z.object({ + id: z.number().optional(), + email: z.string().toLowerCase().email().min(1), + name: z.string(), + role: z.nativeEnum(RecipientRole), + signingOrder: z.number().optional(), + fields: ZFieldAndMetaSchema.and( + z.object({ + id: z.number().optional(), + pageNumber: ZFieldPageNumberSchema, + pageX: ZFieldPageXSchema, + pageY: ZFieldPageYSchema, + width: ZFieldWidthSchema, + height: ZFieldHeightSchema, + }), + ) + .array() + .optional(), + }), + ), meta: z .object({ subject: ZDocumentMetaSubjectSchema.optional(), diff --git a/packages/trpc/server/field-router/router.ts b/packages/trpc/server/field-router/router.ts index 1e022749c..462635544 100644 --- a/packages/trpc/server/field-router/router.ts +++ b/packages/trpc/server/field-router/router.ts @@ -274,6 +274,7 @@ export const fieldRouter = router({ fields: fields.map((field) => ({ id: field.nativeId, signerEmail: field.signerEmail, + recipientId: field.recipientId, type: field.type, pageNumber: field.pageNumber, pageX: field.pageX, @@ -513,6 +514,7 @@ export const fieldRouter = router({ fields: fields.map((field) => ({ id: field.nativeId, signerEmail: field.signerEmail, + recipientId: field.recipientId, type: field.type, pageNumber: field.pageNumber, pageX: field.pageX, diff --git a/packages/trpc/server/field-router/schema.ts b/packages/trpc/server/field-router/schema.ts index fcb26e0f2..89912f5d8 100644 --- a/packages/trpc/server/field-router/schema.ts +++ b/packages/trpc/server/field-router/schema.ts @@ -114,6 +114,7 @@ export const ZSetDocumentFieldsRequestSchema = z.object({ nativeId: z.number().optional(), type: z.nativeEnum(FieldType), signerEmail: z.string().min(1), + recipientId: z.number().min(1), pageNumber: z.number().min(1), pageX: z.number().min(0), pageY: z.number().min(0), @@ -136,6 +137,7 @@ export const ZSetFieldsForTemplateRequestSchema = z.object({ nativeId: z.number().optional(), type: z.nativeEnum(FieldType), signerEmail: z.string().min(1), + recipientId: z.number().min(1), pageNumber: z.number().min(1), pageX: z.number().min(0), pageY: z.number().min(0), diff --git a/packages/trpc/server/recipient-router/schema.ts b/packages/trpc/server/recipient-router/schema.ts index dbc25a497..2aa0f15c0 100644 --- a/packages/trpc/server/recipient-router/schema.ts +++ b/packages/trpc/server/recipient-router/schema.ts @@ -50,16 +50,7 @@ export const ZCreateDocumentRecipientResponseSchema = ZRecipientLiteSchema; export const ZCreateDocumentRecipientsRequestSchema = z.object({ documentId: z.number(), - recipients: z.array(ZCreateRecipientSchema).refine( - (recipients) => { - const emails = recipients.map((recipient) => recipient.email.toLowerCase()); - - return new Set(emails).size === emails.length; - }, - { - message: 'Recipients must have unique emails', - }, - ), + recipients: z.array(ZCreateRecipientSchema), }); export const ZCreateDocumentRecipientsResponseSchema = z.object({ @@ -75,18 +66,7 @@ export const ZUpdateDocumentRecipientResponseSchema = ZRecipientSchema; export const ZUpdateDocumentRecipientsRequestSchema = z.object({ documentId: z.number(), - recipients: z.array(ZUpdateRecipientSchema).refine( - (recipients) => { - const emails = recipients - .filter((recipient) => recipient.email !== undefined) - .map((recipient) => recipient.email?.toLowerCase()); - - return new Set(emails).size === emails.length; - }, - { - message: 'Recipients must have unique emails', - }, - ), + recipients: z.array(ZUpdateRecipientSchema), }); export const ZUpdateDocumentRecipientsResponseSchema = z.object({ @@ -97,29 +77,19 @@ export const ZDeleteDocumentRecipientRequestSchema = z.object({ recipientId: z.number(), }); -export const ZSetDocumentRecipientsRequestSchema = z - .object({ - documentId: z.number(), - recipients: z.array( - z.object({ - nativeId: z.number().optional(), - email: z.string().toLowerCase().email().min(1).max(254), - name: z.string().max(255), - role: z.nativeEnum(RecipientRole), - signingOrder: z.number().optional(), - actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]), - }), - ), - }) - .refine( - (schema) => { - const emails = schema.recipients.map((recipient) => recipient.email.toLowerCase()); - - return new Set(emails).size === emails.length; - }, - // Dirty hack to handle errors when .root is populated for an array type - { message: 'Recipients must have unique emails', path: ['recipients__root'] }, - ); +export const ZSetDocumentRecipientsRequestSchema = z.object({ + documentId: z.number(), + recipients: z.array( + z.object({ + nativeId: z.number().optional(), + email: z.string().toLowerCase().email().min(1).max(254), + name: z.string().max(255), + role: z.nativeEnum(RecipientRole), + signingOrder: z.number().optional(), + actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]), + }), + ), +}); export const ZSetDocumentRecipientsResponseSchema = z.object({ recipients: ZRecipientLiteSchema.array(), @@ -134,16 +104,7 @@ export const ZCreateTemplateRecipientResponseSchema = ZRecipientLiteSchema; export const ZCreateTemplateRecipientsRequestSchema = z.object({ templateId: z.number(), - recipients: z.array(ZCreateRecipientSchema).refine( - (recipients) => { - const emails = recipients.map((recipient) => recipient.email); - - return new Set(emails).size === emails.length; - }, - { - message: 'Recipients must have unique emails', - }, - ), + recipients: z.array(ZCreateRecipientSchema), }); export const ZCreateTemplateRecipientsResponseSchema = z.object({ @@ -159,18 +120,7 @@ export const ZUpdateTemplateRecipientResponseSchema = ZRecipientSchema; export const ZUpdateTemplateRecipientsRequestSchema = z.object({ templateId: z.number(), - recipients: z.array(ZUpdateRecipientSchema).refine( - (recipients) => { - const emails = recipients - .filter((recipient) => recipient.email !== undefined) - .map((recipient) => recipient.email); - - return new Set(emails).size === emails.length; - }, - { - message: 'Recipients must have unique emails', - }, - ), + recipients: z.array(ZUpdateRecipientSchema), }); export const ZUpdateTemplateRecipientsResponseSchema = z.object({ @@ -181,43 +131,30 @@ export const ZDeleteTemplateRecipientRequestSchema = z.object({ recipientId: z.number(), }); -export const ZSetTemplateRecipientsRequestSchema = z - .object({ - templateId: z.number(), - recipients: z.array( - z.object({ - nativeId: z.number().optional(), - email: z - .string() - .toLowerCase() - .refine( - (email) => { - return ( - isTemplateRecipientEmailPlaceholder(email) || - z.string().email().safeParse(email).success - ); - }, - { message: 'Please enter a valid email address' }, - ), - name: z.string(), - role: z.nativeEnum(RecipientRole), - signingOrder: z.number().optional(), - actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]), - }), - ), - }) - .refine( - (schema) => { - // Filter out placeholder emails and only check uniqueness for actual emails - const nonPlaceholderEmails = schema.recipients - .map((recipient) => recipient.email) - .filter((email) => !isTemplateRecipientEmailPlaceholder(email)); - - return new Set(nonPlaceholderEmails).size === nonPlaceholderEmails.length; - }, - // Dirty hack to handle errors when .root is populated for an array type - { message: 'Recipients must have unique emails', path: ['recipients__root'] }, - ); +export const ZSetTemplateRecipientsRequestSchema = z.object({ + templateId: z.number(), + recipients: z.array( + z.object({ + nativeId: z.number().optional(), + email: z + .string() + .toLowerCase() + .refine( + (email) => { + return ( + isTemplateRecipientEmailPlaceholder(email) || + z.string().email().safeParse(email).success + ); + }, + { message: 'Please enter a valid email address' }, + ), + name: z.string(), + role: z.nativeEnum(RecipientRole), + signingOrder: z.number().optional(), + actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]), + }), + ), +}); export const ZSetTemplateRecipientsResponseSchema = z.object({ recipients: ZRecipientLiteSchema.array(), diff --git a/packages/trpc/server/template-router/schema.ts b/packages/trpc/server/template-router/schema.ts index 452ade10c..a00717f33 100644 --- a/packages/trpc/server/template-router/schema.ts +++ b/packages/trpc/server/template-router/schema.ts @@ -101,12 +101,7 @@ export const ZCreateDocumentFromTemplateRequestSchema = z.object({ name: z.string().max(255).optional(), }), ) - .describe('The information of the recipients to create the document with.') - .refine((recipients) => { - const emails = recipients.map((signer) => signer.email); - - return new Set(emails).size === emails.length; - }, 'Recipients must have unique emails'), + .describe('The information of the recipients to create the document with.'), distributeDocument: z .boolean() .describe('Whether to create the document as pending and distribute it to recipients.') diff --git a/packages/ui/components/document/document-read-only-fields.tsx b/packages/ui/components/document/document-read-only-fields.tsx index d1c96f8a9..4ea0d6c89 100644 --- a/packages/ui/components/document/document-read-only-fields.tsx +++ b/packages/ui/components/document/document-read-only-fields.tsx @@ -105,6 +105,7 @@ export const DocumentReadOnlyFields = ({ (null); @@ -103,6 +110,7 @@ export function FieldRootContainer({ field, children, color, className }: FieldR ref={ref} data-field-type={field.type} data-inserted={field.inserted ? 'true' : 'false'} + data-readonly={readonly ? 'true' : 'false'} className={cn( 'field--FieldRootContainer field-card-container dark-mode-disabled group relative z-20 flex h-full w-full items-center rounded-[2px] bg-white/90 ring-2 ring-gray-200 transition-all', color?.base, diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx index 820696e0e..eef021fe0 100644 --- a/packages/ui/primitives/document-flow/add-fields.tsx +++ b/packages/ui/primitives/document-flow/add-fields.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; @@ -46,7 +47,7 @@ import { Form } from '../form/form'; import { RecipientSelector } from '../recipient-selector'; import { useStep } from '../stepper'; import { useToast } from '../use-toast'; -import type { TAddFieldsFormSchema } from './add-fields.types'; +import { type TAddFieldsFormSchema, ZAddFieldsFormSchema } from './add-fields.types'; import { DocumentFlowFormContainerActions, DocumentFlowFormContainerContent, @@ -75,6 +76,7 @@ export type FieldFormType = { pageWidth: number; pageHeight: number; signerEmail: string; + recipientId: number; fieldMeta?: FieldMeta; }; @@ -127,9 +129,11 @@ export const AddFieldsFormPartial = ({ pageHeight: Number(field.height), signerEmail: recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '', + recipientId: field.recipientId, fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined, })), }, + resolver: zodResolver(ZAddFieldsFormSchema), }); useHotkeys(['ctrl+c', 'meta+c'], (evt) => onFieldCopy(evt)); @@ -323,6 +327,7 @@ export const AddFieldsFormPartial = ({ const field = { formId: nanoid(12), + nativeId: undefined, type: selectedField, pageNumber, pageX, @@ -330,6 +335,7 @@ export const AddFieldsFormPartial = ({ pageWidth: fieldPageWidth, pageHeight: fieldPageHeight, signerEmail: selectedSigner.email, + recipientId: selectedSigner.id, fieldMeta: undefined, }; @@ -414,6 +420,7 @@ export const AddFieldsFormPartial = ({ nativeId: undefined, formId: nanoid(12), signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail, + recipientId: selectedSigner?.id ?? lastActiveField.recipientId, pageX: lastActiveField.pageX + 3, pageY: lastActiveField.pageY + 3, }; @@ -438,6 +445,7 @@ export const AddFieldsFormPartial = ({ nativeId: undefined, formId: nanoid(12), signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail, + recipientId: selectedSigner?.id ?? lastActiveField.recipientId, pageNumber, }; @@ -470,6 +478,7 @@ export const AddFieldsFormPartial = ({ nativeId: undefined, formId: nanoid(12), signerEmail: selectedSigner?.email ?? copiedField.signerEmail, + recipientId: selectedSigner?.id ?? copiedField.recipientId, pageX: copiedField.pageX + 3, pageY: copiedField.pageY + 3, }); @@ -663,7 +672,7 @@ export const AddFieldsFormPartial = ({ {isDocumentPdfLoaded && localFields.map((field, index) => { - const recipientIndex = recipients.findIndex((r) => r.email === field.signerEmail); + const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId); const hasFieldError = emptyCheckboxFields.find((f) => f.formId === field.formId) || emptyRadioFields.find((f) => f.formId === field.formId) || diff --git a/packages/ui/primitives/document-flow/add-fields.types.ts b/packages/ui/primitives/document-flow/add-fields.types.ts index 6ec2fade3..7e1a4a170 100644 --- a/packages/ui/primitives/document-flow/add-fields.types.ts +++ b/packages/ui/primitives/document-flow/add-fields.types.ts @@ -10,6 +10,7 @@ export const ZAddFieldsFormSchema = z.object({ nativeId: z.number().optional(), type: z.nativeEnum(FieldType), signerEmail: z.string().min(1), + recipientId: z.number().min(1), pageNumber: z.number().min(1), pageX: z.number().min(0), pageY: z.number().min(0), diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index 43caa8fe1..bd86d8ea5 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -53,6 +53,10 @@ import { import { SigningOrderConfirmation } from './signing-order-confirmation'; import type { DocumentFlowStep } from './types'; +type AutoSaveResponse = { + recipients: Recipient[]; +}; + export type AddSignersFormProps = { documentFlow: DocumentFlowStep; recipients: Recipient[]; @@ -60,7 +64,7 @@ export type AddSignersFormProps = { signingOrder?: DocumentSigningOrder | null; allowDictateNextSigner?: boolean; onSubmit: (_data: TAddSignersFormSchema) => void; - onAutoSave: (_data: TAddSignersFormSchema) => Promise; + onAutoSave: (_data: TAddSignersFormSchema) => Promise; isDocumentPdfLoaded: boolean; }; @@ -208,7 +212,44 @@ export const AddSignersFormPartial = ({ const formData = form.getValues(); - scheduleSave(formData); + scheduleSave(formData, (response) => { + // Sync the response recipients back to form state to prevent duplicates + if (response?.recipients) { + const currentSigners = form.getValues('signers'); + const updatedSigners = currentSigners.map((signer) => { + // Find the matching recipient from the response using nativeId + const matchingRecipient = response.recipients.find( + (recipient) => recipient.id === signer.nativeId, + ); + + if (matchingRecipient) { + // Update the signer with the server-returned data, especially the ID + return { + ...signer, + nativeId: matchingRecipient.id, + }; + } + + // For new signers without nativeId, match by email and update with server ID + if (!signer.nativeId) { + const newRecipient = response.recipients.find( + (recipient) => recipient.email === signer.email, + ); + if (newRecipient) { + return { + ...signer, + nativeId: newRecipient.id, + }; + } + } + + return signer; + }); + + // Update the form state with the synced data + form.setValue('signers', updatedSigners, { shouldValidate: false }); + } + }); }; const emptySignerIndex = watchedSigners.findIndex((signer) => !signer.name && !signer.email); diff --git a/packages/ui/primitives/document-flow/add-signers.types.ts b/packages/ui/primitives/document-flow/add-signers.types.ts index 05e6fea73..ec56c253b 100644 --- a/packages/ui/primitives/document-flow/add-signers.types.ts +++ b/packages/ui/primitives/document-flow/add-signers.types.ts @@ -4,33 +4,23 @@ import { z } from 'zod'; import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth'; -export const ZAddSignersFormSchema = z - .object({ - signers: z.array( - z.object({ - formId: z.string().min(1), - nativeId: z.number().optional(), - email: z - .string() - .email({ message: msg`Invalid email`.id }) - .min(1), - name: z.string(), - role: z.nativeEnum(RecipientRole), - signingOrder: z.number().optional(), - actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]), - }), - ), - signingOrder: z.nativeEnum(DocumentSigningOrder), - allowDictateNextSigner: z.boolean().default(false), - }) - .refine( - (schema) => { - const emails = schema.signers.map((signer) => signer.email.toLowerCase()); - - return new Set(emails).size === emails.length; - }, - // Dirty hack to handle errors when .root is populated for an array type - { message: msg`Signers must have unique emails`.id, path: ['signers__root'] }, - ); +export const ZAddSignersFormSchema = z.object({ + signers: z.array( + z.object({ + formId: z.string().min(1), + nativeId: z.number().optional(), + email: z + .string() + .email({ message: msg`Invalid email`.id }) + .min(1), + name: z.string(), + role: z.nativeEnum(RecipientRole), + signingOrder: z.number().optional(), + actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]), + }), + ), + signingOrder: z.nativeEnum(DocumentSigningOrder), + allowDictateNextSigner: z.boolean().default(false), +}); export type TAddSignersFormSchema = z.infer; diff --git a/packages/ui/primitives/document-flow/field-item.tsx b/packages/ui/primitives/document-flow/field-item.tsx index da09ed471..40c2684da 100644 --- a/packages/ui/primitives/document-flow/field-item.tsx +++ b/packages/ui/primitives/document-flow/field-item.tsx @@ -299,6 +299,8 @@ export const FieldItem = ({ }} ref={$el} data-field-id={field.nativeId} + data-field-type={field.type} + data-recipient-id={field.recipientId} > diff --git a/packages/ui/primitives/document-flow/types.ts b/packages/ui/primitives/document-flow/types.ts index 2e57f0ff0..1551ed72c 100644 --- a/packages/ui/primitives/document-flow/types.ts +++ b/packages/ui/primitives/document-flow/types.ts @@ -8,19 +8,14 @@ import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta'; export const ZDocumentFlowFormSchema = z.object({ title: z.string().min(1), - signers: z - .array( - z.object({ - formId: z.string().min(1), - nativeId: z.number().optional(), - email: z.string().min(1).email(), - name: z.string(), - }), - ) - .refine((signers) => { - const emails = signers.map((signer) => signer.email); - return new Set(emails).size === emails.length; - }, 'Signers must have unique emails'), + signers: z.array( + z.object({ + formId: z.string().min(1), + nativeId: z.number().optional(), + email: z.string().min(1).email(), + name: z.string(), + }), + ), fields: z.array( z.object({ @@ -28,6 +23,7 @@ export const ZDocumentFlowFormSchema = z.object({ nativeId: z.number().optional(), type: z.nativeEnum(FieldType), signerEmail: z.string().min(1).optional(), + recipientId: z.number().min(1), pageNumber: z.number().min(1), pageX: z.number().min(0), pageY: z.number().min(0), diff --git a/packages/ui/primitives/template-flow/add-template-fields.tsx b/packages/ui/primitives/template-flow/add-template-fields.tsx index b6471bc61..7db417036 100644 --- a/packages/ui/primitives/template-flow/add-template-fields.tsx +++ b/packages/ui/primitives/template-flow/add-template-fields.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; @@ -61,7 +62,10 @@ import type { FieldFormType } from '../document-flow/add-fields'; import { FieldAdvancedSettings } from '../document-flow/field-item-advanced-settings'; import { Form } from '../form/form'; import { useStep } from '../stepper'; -import type { TAddTemplateFieldsFormSchema } from './add-template-fields.types'; +import { + type TAddTemplateFieldsFormSchema, + ZAddTemplateFieldsFormSchema, +} from './add-template-fields.types'; const MIN_HEIGHT_PX = 12; const MIN_WIDTH_PX = 36; @@ -112,7 +116,7 @@ export const AddTemplateFieldsFormPartial = ({ pageY: Number(field.positionY), pageWidth: Number(field.width), pageHeight: Number(field.height), - signerId: field.recipientId ?? -1, + recipientId: field.recipientId ?? -1, signerEmail: recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '', signerToken: @@ -120,6 +124,7 @@ export const AddTemplateFieldsFormPartial = ({ fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined, })), }, + resolver: zodResolver(ZAddTemplateFieldsFormSchema), }); const onFormSubmit = form.handleSubmit(onSubmit); @@ -170,7 +175,7 @@ export const AddTemplateFieldsFormPartial = ({ nativeId: undefined, formId: nanoid(12), signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail, - signerId: selectedSigner?.id ?? lastActiveField.signerId, + recipientId: selectedSigner?.id ?? lastActiveField.recipientId, signerToken: selectedSigner?.token ?? lastActiveField.signerToken, pageX: lastActiveField.pageX + 3, pageY: lastActiveField.pageY + 3, @@ -197,7 +202,7 @@ export const AddTemplateFieldsFormPartial = ({ nativeId: undefined, formId: nanoid(12), signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail, - signerId: selectedSigner?.id ?? lastActiveField.signerId, + recipientId: selectedSigner?.id ?? lastActiveField.recipientId, signerToken: selectedSigner?.token ?? lastActiveField.signerToken, pageNumber, }; @@ -240,7 +245,7 @@ export const AddTemplateFieldsFormPartial = ({ formId: nanoid(12), nativeId: undefined, signerEmail: selectedSigner?.email ?? copiedField.signerEmail, - signerId: selectedSigner?.id ?? copiedField.signerId, + recipientId: selectedSigner?.id ?? copiedField.recipientId, signerToken: selectedSigner?.token ?? copiedField.signerToken, pageX: copiedField.pageX + 3, pageY: copiedField.pageY + 3, @@ -371,7 +376,7 @@ export const AddTemplateFieldsFormPartial = ({ pageWidth: fieldPageWidth, pageHeight: fieldPageHeight, signerEmail: selectedSigner.email, - signerId: selectedSigner.id, + recipientId: selectedSigner.id, signerToken: selectedSigner.token ?? '', fieldMeta: undefined, }; @@ -597,14 +602,14 @@ export const AddTemplateFieldsFormPartial = ({ )} {localFields.map((field, index) => { - const recipientIndex = recipients.findIndex((r) => r.email === field.signerEmail); + const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId); return ( void; - onAutoSave: (_data: TAddTemplatePlacholderRecipientsFormSchema) => Promise; + onAutoSave: (_data: TAddTemplatePlacholderRecipientsFormSchema) => Promise; isDocumentPdfLoaded: boolean; }; @@ -146,7 +150,44 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ const formData = form.getValues(); - scheduleSave(formData); + scheduleSave(formData, (response) => { + // Sync the response recipients back to form state to prevent duplicates + if (response?.recipients) { + const currentSigners = form.getValues('signers'); + const updatedSigners = currentSigners.map((signer) => { + // Find the matching recipient from the response using nativeId + const matchingRecipient = response.recipients.find( + (recipient) => recipient.id === signer.nativeId, + ); + + if (matchingRecipient) { + // Update the signer with the server-returned data, especially the ID + return { + ...signer, + nativeId: matchingRecipient.id, + }; + } + + // For new signers without nativeId, match by email and update with server ID + if (!signer.nativeId) { + const newRecipient = response.recipients.find( + (recipient) => recipient.email === signer.email, + ); + if (newRecipient) { + return { + ...signer, + nativeId: newRecipient.id, + }; + } + } + + return signer; + }); + + // Update the form state with the synced data + form.setValue('signers', updatedSigners, { shouldValidate: false }); + } + }); }; // useEffect(() => { diff --git a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.types.ts b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.types.ts index 187f99e57..538f3cef1 100644 --- a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.types.ts +++ b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.types.ts @@ -1,7 +1,6 @@ import { DocumentSigningOrder, RecipientRole } from '@prisma/client'; import { z } from 'zod'; -import { TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX } from '@documenso/lib/constants/template'; import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth'; export const ZAddTemplatePlacholderRecipientsFormSchema = z @@ -20,17 +19,7 @@ export const ZAddTemplatePlacholderRecipientsFormSchema = z signingOrder: z.nativeEnum(DocumentSigningOrder), allowDictateNextSigner: z.boolean().default(false), }) - .refine( - (schema) => { - const nonPlaceholderEmails = schema.signers - .map((signer) => signer.email.toLowerCase()) - .filter((email) => !TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX.test(email)); - return new Set(nonPlaceholderEmails).size === nonPlaceholderEmails.length; - }, - // Dirty hack to handle errors when .root is populated for an array type - { message: 'Signers must have unique emails', path: ['signers__root'] }, - ) .refine( /* Since placeholder emails are empty, we need to check that the names are unique.