diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx index 3c2430a37..cff9bab64 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx @@ -7,7 +7,12 @@ import { useOptionalSession } from '@documenso/lib/client-only/providers/session import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema'; import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; import { nanoid } from '@documenso/lib/universal/id'; -import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients'; +import { + isAssistantLastSigner, + isCcRecipient, + normalizeRecipientSigningOrders, + canRecipientBeModified as utilCanRecipientBeModified, +} from '@documenso/lib/utils/recipients'; import { trpc } from '@documenso/trpc/react'; import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select'; import { @@ -156,16 +161,12 @@ export const EnvelopeEditorRecipientForm = () => { }, [watchedSigners]); const normalizeSigningOrders = (signers: typeof watchedSigners) => { - return signers - .sort((a, b) => (a.signingOrder ?? 0) - (b.signingOrder ?? 0)) - .map((signer, index) => ({ ...signer, signingOrder: index + 1 })); + return normalizeRecipientSigningOrders(signers, (signer) => canRecipientBeModified(signer.id)); }; - const { - append: appendSigner, - fields: signers, - remove: removeSigner, - } = useFieldArray({ + const activeRecipientCount = watchedSigners.filter((signer) => !isCcRecipient(signer)).length; + + const { fields: signers, remove: removeSigner } = useFieldArray({ control, name: 'signers', keyName: 'nativeId', @@ -208,14 +209,31 @@ export const EnvelopeEditorRecipientForm = () => { return utilCanRecipientBeModified(recipient, fields); }; + const appendNormalizedSigner = (signer: (typeof watchedSigners)[number], shouldFocus = false) => { + const updatedSigners = normalizeSigningOrders([...form.getValues('signers'), signer]); + + form.setValue('signers', updatedSigners, { + shouldValidate: true, + shouldDirty: true, + }); + + if (shouldFocus) { + const signerIndex = updatedSigners.findIndex((updatedSigner) => updatedSigner.formId === signer.formId); + + if (signerIndex !== -1) { + requestAnimationFrame(() => form.setFocus(`signers.${signerIndex}.email`)); + } + } + }; + const onAddSigner = () => { - appendSigner({ + appendNormalizedSigner({ formId: nanoid(12), name: '', email: '', role: RecipientRole.SIGNER, actionAuth: [], - signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1, + signingOrder: activeRecipientCount + 1, }); }; @@ -323,18 +341,16 @@ export const EnvelopeEditorRecipientForm = () => { form.setFocus(`signers.${emptySignerIndex}.email`); } else { - appendSigner( + appendNormalizedSigner( { formId: nanoid(12), name: currentEditorName ?? '', email: currentEditorEmail ?? '', role: RecipientRole.SIGNER, actionAuth: [], - signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1, - }, - { - shouldFocus: true, + signingOrder: activeRecipientCount + 1, }, + true, ); void form.trigger('signers'); @@ -369,18 +385,14 @@ export const EnvelopeEditorRecipientForm = () => { items.splice(insertIndex, 0, reorderedSigner); - const updatedSigners = items.map((signer, index) => ({ - ...signer, - signingOrder: !canRecipientBeModified(signer.id) ? signer.signingOrder : index + 1, - })); + const updatedSigners = normalizeSigningOrders(items); form.setValue('signers', updatedSigners, { shouldValidate: true, shouldDirty: true, }); - const lastSigner = updatedSigners[updatedSigners.length - 1]; - if (lastSigner.role === RecipientRole.ASSISTANT) { + if (isAssistantLastSigner(updatedSigners)) { toast({ title: t`Warning: Assistant as last signer`, description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`, @@ -411,18 +423,19 @@ export const EnvelopeEditorRecipientForm = () => { return; } - const updatedSigners = currentSigners.map((signer, idx) => ({ - ...signer, - role: idx === index ? role : signer.role, - signingOrder: !canRecipientBeModified(signer.id) ? signer.signingOrder : idx + 1, - })); + const updatedSigners = normalizeSigningOrders( + currentSigners.map((signer, idx) => ({ + ...signer, + role: idx === index ? role : signer.role, + })), + ); form.setValue('signers', updatedSigners, { shouldValidate: true, shouldDirty: true, }); - if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) { + if (role === RecipientRole.ASSISTANT && isAssistantLastSigner(updatedSigners)) { toast({ title: t`Warning: Assistant as last signer`, description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`, @@ -447,22 +460,30 @@ export const EnvelopeEditorRecipientForm = () => { const currentSigners = form.getValues('signers'); const signer = currentSigners[index]; - // Remove signer from current position and insert at new position - const remainingSigners = currentSigners.filter((_, idx) => idx !== index); - const newPosition = Math.min(Math.max(0, newOrder - 1), currentSigners.length - 1); - remainingSigners.splice(newPosition, 0, signer); + if (isCcRecipient(signer)) { + return; + } - const updatedSigners = remainingSigners.map((s, idx) => ({ - ...s, - signingOrder: !canRecipientBeModified(s.id) ? s.signingOrder : idx + 1, - })); + const nonCcSigners = currentSigners.filter((s) => !isCcRecipient(s)); + const ccSigners = currentSigners.filter((s) => isCcRecipient(s)); + const currentSigningOrderIndex = nonCcSigners.findIndex((s) => s.formId === signer.formId); + + if (currentSigningOrderIndex === -1) { + return; + } + + const [reorderedSigner] = nonCcSigners.splice(currentSigningOrderIndex, 1); + const newPosition = Math.min(Math.max(0, newOrder - 1), nonCcSigners.length); + nonCcSigners.splice(newPosition, 0, reorderedSigner); + + const updatedSigners = normalizeSigningOrders([...nonCcSigners, ...ccSigners]); form.setValue('signers', updatedSigners, { shouldValidate: true, shouldDirty: true, }); - if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) { + if (signer.role === RecipientRole.ASSISTANT && isAssistantLastSigner(updatedSigners)) { toast({ title: t`Warning: Assistant as last signer`, description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`, @@ -476,10 +497,12 @@ export const EnvelopeEditorRecipientForm = () => { setShowSigningOrderConfirmation(false); const currentSigners = form.getValues('signers'); - const updatedSigners = currentSigners.map((signer) => ({ - ...signer, - role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role, - })); + const updatedSigners = normalizeSigningOrders( + currentSigners.map((signer) => ({ + ...signer, + role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role, + })), + ); form.setValue('signers', updatedSigners, { shouldValidate: true, @@ -796,6 +819,7 @@ export const EnvelopeEditorRecipientForm = () => { isDragDisabled={ !isSigningOrderSequential || isSubmitting || + isCcRecipient(signer) || !canRecipientBeModified(signer.id) || !signer.signingOrder } @@ -819,7 +843,11 @@ export const EnvelopeEditorRecipientForm = () => { })} >
- {isSigningOrderSequential && ( + {isSigningOrderSequential && isCcRecipient(signer) && ( +
+ )} + + {isSigningOrderSequential && !isCcRecipient(signer) && ( { { onValueChange={(value) => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions handleRoleChange(index, value as RecipientRole); - field.onChange(value); }} disabled={ snapshot.isDragging || isSubmitting || !canRecipientBeModified(signer.id) diff --git a/package-lock.json b/package-lock.json index 0109d8c0f..2f7ef057b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2952,9 +2952,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -2972,9 +2969,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -2992,9 +2986,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -3012,9 +3003,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -25026,9 +25014,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -25045,9 +25030,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -25064,9 +25046,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -25083,9 +25062,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ 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 01cfb82bb..081389aee 100644 --- a/packages/app-tests/e2e/api/v1/document-sending.spec.ts +++ b/packages/app-tests/e2e/api/v1/document-sending.spec.ts @@ -2,10 +2,20 @@ 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 { FieldType, RecipientRole } from '@documenso/prisma/client'; +import { + DocumentSigningOrder, + DocumentStatus, + FieldType, + RecipientRole, + SendStatus, + SigningStatus, +} from '@documenso/prisma/client'; import { seedBlankDocument, seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents'; import { seedUser } from '@documenso/prisma/seed/users'; import { expect, test } from '@playwright/test'; +import { nanoid } from 'nanoid'; + +import { signSignaturePad } from '../../fixtures/signature'; test.describe('Document API', () => { test('sendDocument: should respect sendCompletionEmails setting', async ({ request }) => { @@ -432,4 +442,194 @@ test.describe('Document API', () => { expect(response.ok()).toBeTruthy(); expect(response.status()).toBe(200); }); + + test('sendDocument: should complete document immediately when all recipients are CC', 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 two CC recipients without any fields, mirroring the production + // state where CC recipients are created pre-signed. + for (const email of ['cc1@example.com', 'cc2@example.com']) { + await prisma.recipient.create({ + data: { + email, + name: 'Test CC', + role: RecipientRole.CC, + signingStatus: SigningStatus.SIGNED, + sendStatus: SendStatus.SENT, + token: nanoid(), + 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); + + // The document seals asynchronously and completes without anyone signing. + await expect + .poll( + async () => { + const updatedDocument = await prisma.envelope.findFirstOrThrow({ + where: { id: document.id }, + }); + + return updatedDocument.status; + }, + { timeout: 30_000 }, + ) + .toBe(DocumentStatus.COMPLETED); + }); + + test('sendDocument: should not block initial sequential send when CC recipient is first in signing order', async ({ + request, + page, + }) => { + 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 }, + }); + + await prisma.documentMeta.update({ + where: { id: document.documentMetaId }, + data: { signingOrder: DocumentSigningOrder.SEQUENTIAL }, + }); + + // CC recipient first in the signing order, mirroring the production + // state where CC recipients are created pre-signed. + await prisma.recipient.create({ + data: { + email: 'cc@example.com', + name: 'Test CC', + role: RecipientRole.CC, + signingOrder: 1, + signingStatus: SigningStatus.SIGNED, + sendStatus: SendStatus.SENT, + token: nanoid(), + envelopeId: document.id, + }, + }); + + const [signerA, signerB] = await Promise.all( + [ + { email: 'signer-a@example.com', name: 'Signer A', signingOrder: 2 }, + { email: 'signer-b@example.com', name: 'Signer B', signingOrder: 3 }, + ].map(async ({ email, name, signingOrder }) => + prisma.recipient.create({ + data: { + email, + name, + role: RecipientRole.SIGNER, + signingOrder, + token: nanoid(), + envelopeId: document.id, + fields: { + create: { + type: FieldType.SIGNATURE, + page: 1, + positionX: signingOrder * 10, + positionY: 10, + width: 5, + height: 5, + customText: '', + inserted: false, + envelopeId: document.id, + envelopeItemId: document.envelopeItems[0].id, + fieldMeta: { type: 'signature', fontSize: 14 }, + }, + }, + }, + }), + ), + ); + + 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); + + // The CC recipient at order 1 must not block signer A at order 2. + await page.goto(`/sign/${signerA.token}`); + await expect(page).not.toHaveURL(`/sign/${signerA.token}/waiting`); + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + + // Signer B at order 3 must still wait for signer A. + await page.goto(`/sign/${signerB.token}`); + await expect(page).toHaveURL(`/sign/${signerB.token}/waiting`); + + // Sign as signer A then signer B. + for (const signer of [signerA, signerB]) { + await page.goto(`/sign/${signer.token}`); + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + await signSignaturePad(page); + + const signerField = await prisma.field.findFirstOrThrow({ + where: { recipientId: signer.id }, + }); + + await page.locator(`#field-${signerField.id}`).getByRole('button').click(); + + await page.getByRole('button', { name: 'Complete' }).click(); + await page.getByRole('button', { name: 'Sign' }).click(); + await page.waitForURL(`/sign/${signer.token}/complete`); + } + + // The document completes without any action from the CC recipient. + await expect + .poll( + async () => { + const updatedDocument = await prisma.envelope.findFirstOrThrow({ + where: { id: document.id }, + }); + + return updatedDocument.status; + }, + { timeout: 30_000 }, + ) + .toBe(DocumentStatus.COMPLETED); + }); }); diff --git a/packages/app-tests/e2e/document-auth/next-recipient-dictation.spec.ts b/packages/app-tests/e2e/document-auth/next-recipient-dictation.spec.ts index f54eb75a0..a0c536a20 100644 --- a/packages/app-tests/e2e/document-auth/next-recipient-dictation.spec.ts +++ b/packages/app-tests/e2e/document-auth/next-recipient-dictation.spec.ts @@ -2,7 +2,14 @@ import { prisma } from '@documenso/prisma'; import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents'; import { seedUser } from '@documenso/prisma/seed/users'; import { expect, test } from '@playwright/test'; -import { DocumentSigningOrder, DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@prisma/client'; +import { + DocumentSigningOrder, + DocumentStatus, + FieldType, + RecipientRole, + SendStatus, + SigningStatus, +} from '@prisma/client'; import { signDirectSignaturePad, signSignaturePad } from '../fixtures/signature'; @@ -370,3 +377,221 @@ test('[NEXT_RECIPIENT_DICTATION]: should allow assistant to dictate next signer' expect(thirdRecipient.role).toBe(RecipientRole.SIGNER); }).toPass(); }); + +test('[NEXT_RECIPIENT_DICTATION]: should skip CC recipient when dictating next signer', async ({ page }) => { + const { user, team } = await seedUser(); + const { user: firstSigner } = await seedUser(); + const { user: ccUser } = await seedUser(); + const { user: secondSigner } = await seedUser(); + + const { recipients, document } = await seedPendingDocumentWithFullFields({ + owner: user, + teamId: team.id, + recipients: [firstSigner, ccUser, secondSigner], + recipientsCreateOptions: [ + { signingOrder: 1 }, + { + // CC recipients are created pre-signed, mirroring production behaviour. + signingOrder: 2, + role: RecipientRole.CC, + signingStatus: SigningStatus.SIGNED, + sendStatus: SendStatus.SENT, + }, + { signingOrder: 3 }, + ], + updateDocumentOptions: { + documentMeta: { + upsert: { + create: { + allowDictateNextSigner: true, + signingOrder: DocumentSigningOrder.SEQUENTIAL, + }, + update: { + allowDictateNextSigner: true, + signingOrder: DocumentSigningOrder.SEQUENTIAL, + }, + }, + }, + }, + }); + + const firstRecipient = recipients.find((r) => r.email === firstSigner.email); + const ccRecipient = recipients.find((r) => r.email === ccUser.email); + + if (!firstRecipient || !ccRecipient) { + throw new Error('Recipients not found'); + } + + // CC recipients cannot have fields. + await prisma.field.deleteMany({ + where: { + recipientId: ccRecipient.id, + }, + }); + + const { token, fields } = firstRecipient; + + const signUrl = `/sign/${token}`; + + await page.goto(signUrl); + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + + await signSignaturePad(page); + + // Fill in all fields + for (const field of fields) { + await page.locator(`#field-${field.id}`).getByRole('button').click(); + + if (field.type === FieldType.TEXT) { + await page.locator('#custom-text').fill('TEXT'); + await page.getByRole('button', { name: 'Save' }).click(); + } + + await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true'); + } + + // Complete signing and verify the offered next recipient + await page.getByRole('button', { name: 'Complete' }).click(); + + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page.getByText('Next Recipient Name')).toBeVisible(); + + // The dictation dialog must offer the second signer, not the CC recipient. + const dialog = page.getByRole('dialog'); + await expect(dialog.getByLabel('Name')).toHaveValue(secondSigner.name ?? ''); + await expect(dialog.getByLabel('Email')).toHaveValue(secondSigner.email); + + // Submit and verify completion + await page.getByRole('button', { name: 'Sign' }).click(); + await page.waitForURL(`${signUrl}/complete`); + + // Verify document and recipient states + const updatedDocument = await prisma.envelope.findUniqueOrThrow({ + where: { id: document.id }, + include: { + recipients: { + orderBy: { signingOrder: 'asc' }, + }, + }, + }); + + // Document should still be pending as the second signer has not signed + expect(updatedDocument.status).toBe(DocumentStatus.PENDING); + + // The CC recipient must remain untouched + const updatedCcRecipient = updatedDocument.recipients[1]; + expect(updatedCcRecipient.email).toBe(ccUser.email); + expect(updatedCcRecipient.role).toBe(RecipientRole.CC); + expect(updatedCcRecipient.signingStatus).toBe(SigningStatus.SIGNED); + + // The second signer must remain the next pending recipient + const updatedSecondRecipient = updatedDocument.recipients[2]; + expect(updatedSecondRecipient.email).toBe(secondSigner.email); + expect(updatedSecondRecipient.signingOrder).toBe(3); + expect(updatedSecondRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED); +}); + +test('[NEXT_RECIPIENT_DICTATION]: should not offer dictation when CC recipient is last', async ({ page }) => { + const { user, team } = await seedUser(); + const { user: firstSigner } = await seedUser(); + const { user: secondSigner } = await seedUser(); + const { user: ccUser } = await seedUser(); + + const { recipients, document } = await seedPendingDocumentWithFullFields({ + owner: user, + teamId: team.id, + recipients: [firstSigner, secondSigner, ccUser], + recipientsCreateOptions: [ + { signingOrder: 1 }, + { signingOrder: 2 }, + { + // CC recipients are created pre-signed, mirroring production behaviour. + signingOrder: 3, + role: RecipientRole.CC, + signingStatus: SigningStatus.SIGNED, + sendStatus: SendStatus.SENT, + }, + ], + updateDocumentOptions: { + documentMeta: { + upsert: { + create: { + allowDictateNextSigner: true, + signingOrder: DocumentSigningOrder.SEQUENTIAL, + }, + update: { + allowDictateNextSigner: true, + signingOrder: DocumentSigningOrder.SEQUENTIAL, + }, + }, + }, + }, + }); + + const firstRecipient = recipients.find((r) => r.email === firstSigner.email); + const secondRecipient = recipients.find((r) => r.email === secondSigner.email); + const ccRecipient = recipients.find((r) => r.email === ccUser.email); + + if (!firstRecipient || !secondRecipient || !ccRecipient) { + throw new Error('Recipients not found'); + } + + // CC recipients cannot have fields. + await prisma.field.deleteMany({ + where: { + recipientId: ccRecipient.id, + }, + }); + + // Sign as both signers in order. + for (const recipient of [firstRecipient, secondRecipient]) { + const { token, fields } = recipient; + + const signUrl = `/sign/${token}`; + + await page.goto(signUrl); + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + + await signSignaturePad(page); + + // Fill in all fields + for (const field of fields) { + await page.locator(`#field-${field.id}`).getByRole('button').click(); + + if (field.type === FieldType.TEXT) { + await page.locator('#custom-text').fill('TEXT'); + await page.getByRole('button', { name: 'Save' }).click(); + } + + await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true'); + } + + // Complete signing + await page.getByRole('button', { name: 'Complete' }).click(); + + await expect(page.getByRole('dialog')).toBeVisible(); + + if (recipient.id === secondRecipient.id) { + // The last actionable signer must not be offered the CC recipient. + await expect(page.getByText('Next Recipient Name')).not.toBeVisible(); + } + + // Submit and verify completion + await page.getByRole('button', { name: 'Sign' }).click(); + await page.waitForURL(`${signUrl}/complete`); + } + + // The document completes without any action from the CC recipient. + await expect + .poll( + async () => { + const finalDocument = await prisma.envelope.findUniqueOrThrow({ + where: { id: document.id }, + }); + + return finalDocument.status; + }, + { timeout: 30_000 }, + ) + .toBe(DocumentStatus.COMPLETED); +}); 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 2380dc876..f89937748 100644 --- a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts +++ b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts @@ -4,7 +4,14 @@ import { prisma } from '@documenso/prisma'; import { seedBlankDocument, seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents'; import { seedUser } from '@documenso/prisma/seed/users'; import { expect, test } from '@playwright/test'; -import { DocumentSigningOrder, DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@prisma/client'; +import { + DocumentSigningOrder, + DocumentStatus, + FieldType, + RecipientRole, + SendStatus, + SigningStatus, +} from '@prisma/client'; import { DateTime } from 'luxon'; import { apiSignin } from '../fixtures/authentication'; @@ -225,15 +232,20 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie await page.getByLabel('Receives copy').click(); await page.getByRole('button', { name: 'Add Signer' }).click(); - await page.getByLabel('Email').nth(2).fill('user3@example.com'); - await page.getByLabel('Name').nth(2).fill('User 3'); - await page.getByRole('combobox').nth(2).click(); + // CC recipients are kept last, so new rows are inserted above the CC row. + await expect(page.getByLabel('Email')).toHaveCount(3); + + await page.getByLabel('Email').nth(1).fill('user3@example.com'); + await page.getByLabel('Name').nth(1).fill('User 3'); + await page.getByRole('combobox').nth(1).click(); await page.getByLabel('Needs to approve').click(); await page.getByRole('button', { name: 'Add Signer' }).click(); - await page.getByLabel('Email').nth(3).fill('user4@example.com'); - await page.getByLabel('Name').nth(3).fill('User 4'); - await page.getByRole('combobox').nth(3).click(); + await expect(page.getByLabel('Email')).toHaveCount(4); + + await page.getByLabel('Email').nth(2).fill('user4@example.com'); + await page.getByLabel('Name').nth(2).fill('User 4'); + await page.getByRole('combobox').nth(2).click(); await page.getByLabel('Needs to view').click(); await page.getByRole('button', { name: 'Continue' }).click(); @@ -661,3 +673,182 @@ test('[DOCUMENT_FLOW]: should prevent out-of-order signing in sequential mode', await expect(page).not.toHaveURL(`/sign/${activeRecipient?.token}/waiting`); await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); }); + +test('[DOCUMENT_FLOW]: should skip CC recipients in sequential signing order', async ({ page }) => { + const { user, team } = await seedUser(); + + const { document, recipients } = await seedPendingDocumentWithFullFields({ + teamId: team.id, + owner: user, + recipients: ['signer1@example.com', 'cc@example.com', 'signer2@example.com'], + fields: [FieldType.SIGNATURE], + recipientsCreateOptions: [ + { signingOrder: 1 }, + { + // CC recipients are created pre-signed, mirroring production behaviour. + signingOrder: 2, + role: RecipientRole.CC, + signingStatus: SigningStatus.SIGNED, + sendStatus: SendStatus.SENT, + }, + { signingOrder: 3 }, + ], + }); + + await prisma.documentMeta.update({ + where: { + id: document.documentMetaId, + }, + data: { + signingOrder: DocumentSigningOrder.SEQUENTIAL, + }, + }); + + const firstSigner = recipients.find((r) => r.email === 'signer1@example.com'); + const ccRecipient = recipients.find((r) => r.email === 'cc@example.com'); + const lastSigner = recipients.find((r) => r.email === 'signer2@example.com'); + + // CC recipients cannot have fields. + await prisma.field.deleteMany({ + where: { + recipientId: ccRecipient?.id, + }, + }); + + // Sequential order is enforced: the last signer must wait while the first signer is pending. + await page.goto(`/sign/${lastSigner?.token}`); + await expect(page).toHaveURL(`/sign/${lastSigner?.token}/waiting`); + + // Sign as the first signer. + await page.goto(`/sign/${firstSigner?.token}`); + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + await signSignaturePad(page); + + const firstSignerField = await prisma.field.findFirstOrThrow({ + where: { recipientId: firstSigner?.id }, + }); + + await page.locator(`#field-${firstSignerField.id}`).getByRole('button').click(); + + await page.getByRole('button', { name: 'Complete' }).click(); + await page.getByRole('button', { name: 'Sign' }).click(); + await page.waitForURL(`/sign/${firstSigner?.token}/complete`); + + // The CC recipient at order 2 must not block the last signer at order 3. + await page.goto(`/sign/${lastSigner?.token}`); + await expect(page).not.toHaveURL(`/sign/${lastSigner?.token}/waiting`); + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + + await signSignaturePad(page); + + const lastSignerField = await prisma.field.findFirstOrThrow({ + where: { recipientId: lastSigner?.id }, + }); + + await page.locator(`#field-${lastSignerField.id}`).getByRole('button').click(); + + await page.getByRole('button', { name: 'Complete' }).click(); + await page.getByRole('button', { name: 'Sign' }).click(); + await page.waitForURL(`/sign/${lastSigner?.token}/complete`); + + // The document completes without any action from the CC recipient. + await expect + .poll( + async () => { + const finalDocument = await prisma.envelope.findFirstOrThrow({ + where: { id: document.id }, + }); + + return finalDocument.status; + }, + { timeout: 30_000 }, + ) + .toBe(DocumentStatus.COMPLETED); +}); + +test('[DOCUMENT_FLOW]: should skip unsigned CC recipients in sequential signing order', async ({ page }) => { + const { user, team } = await seedUser(); + + const { document, recipients } = await seedPendingDocumentWithFullFields({ + teamId: team.id, + owner: user, + recipients: ['signer1@example.com', 'cc@example.com', 'signer2@example.com'], + fields: [FieldType.SIGNATURE], + recipientsCreateOptions: [ + { signingOrder: 1 }, + { + // Legacy/inconsistent data: a CC recipient that was never marked as signed. + signingOrder: 2, + role: RecipientRole.CC, + signingStatus: SigningStatus.NOT_SIGNED, + }, + { signingOrder: 3 }, + ], + }); + + await prisma.documentMeta.update({ + where: { + id: document.documentMetaId, + }, + data: { + signingOrder: DocumentSigningOrder.SEQUENTIAL, + }, + }); + + const firstSigner = recipients.find((r) => r.email === 'signer1@example.com'); + const ccRecipient = recipients.find((r) => r.email === 'cc@example.com'); + const lastSigner = recipients.find((r) => r.email === 'signer2@example.com'); + + // CC recipients cannot have fields. + await prisma.field.deleteMany({ + where: { + recipientId: ccRecipient?.id, + }, + }); + + // Sign as the first signer. + await page.goto(`/sign/${firstSigner?.token}`); + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + await signSignaturePad(page); + + const firstSignerField = await prisma.field.findFirstOrThrow({ + where: { recipientId: firstSigner?.id }, + }); + + await page.locator(`#field-${firstSignerField.id}`).getByRole('button').click(); + + await page.getByRole('button', { name: 'Complete' }).click(); + await page.getByRole('button', { name: 'Sign' }).click(); + await page.waitForURL(`/sign/${firstSigner?.token}/complete`); + + // The unsigned CC recipient at order 2 must not block the last signer at order 3. + await page.goto(`/sign/${lastSigner?.token}`); + await expect(page).not.toHaveURL(`/sign/${lastSigner?.token}/waiting`); + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + + await signSignaturePad(page); + + const lastSignerField = await prisma.field.findFirstOrThrow({ + where: { recipientId: lastSigner?.id }, + }); + + await page.locator(`#field-${lastSignerField.id}`).getByRole('button').click(); + + await page.getByRole('button', { name: 'Complete' }).click(); + await page.getByRole('button', { name: 'Sign' }).click(); + await page.waitForURL(`/sign/${lastSigner?.token}/complete`); + + // The document completes without any action from the CC recipient. + await expect + .poll( + async () => { + const finalDocument = await prisma.envelope.findFirstOrThrow({ + where: { id: document.id }, + }); + + return finalDocument.status; + }, + { timeout: 30_000 }, + ) + .toBe(DocumentStatus.COMPLETED); +}); diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-cc-order.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-cc-order.spec.ts new file mode 100644 index 000000000..1286e7230 --- /dev/null +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-cc-order.spec.ts @@ -0,0 +1,155 @@ +import { nanoid } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; +import { seedBlankDocument } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; +import type { Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; +import { DocumentSigningOrder, RecipientRole } from '@prisma/client'; + +import { apiSignin } from '../fixtures/authentication'; +import { + assertRecipientRole, + getRecipientEmailInputs, + getRecipientRows, + getSigningOrderInputs, + openDocumentEnvelopeEditor, + setRecipientEmail, + setRecipientName, + setRecipientRole, + toggleSigningOrder, +} from '../fixtures/envelope-editor'; + +const SIGNER_A = { email: 'cc-order-signer-a@example.com', name: 'Signer A' }; +const SIGNER_B = { email: 'cc-order-signer-b@example.com', name: 'Signer B' }; +const CC_RECIPIENT = { email: 'cc-order-cc@example.com', name: 'CC Recipient' }; + +const assertCcDisplayedLastWithNoOrderInput = async (root: Page) => { + // CC recipient is displayed last despite being added/stored mid-list. + await expect(getRecipientEmailInputs(root)).toHaveCount(3); + await expect(getRecipientEmailInputs(root).nth(0)).toHaveValue(SIGNER_A.email); + await expect(getRecipientEmailInputs(root).nth(1)).toHaveValue(SIGNER_B.email); + await expect(getRecipientEmailInputs(root).nth(2)).toHaveValue(CC_RECIPIENT.email); + + await assertRecipientRole(root, 0, 'Needs to sign'); + await assertRecipientRole(root, 1, 'Needs to sign'); + await assertRecipientRole(root, 2, 'Receives copy'); + + // Only the two signers have signing order inputs, showing 1 and 2. + await expect(getSigningOrderInputs(root)).toHaveCount(2); + await expect(getSigningOrderInputs(root).nth(0)).toHaveValue('1'); + await expect(getSigningOrderInputs(root).nth(1)).toHaveValue('2'); + + // The CC row itself renders no signing order input (placeholder div instead). + const ccRow = getRecipientRows(root).nth(2); + await expect(ccRow.locator('[data-testid="signing-order-input"]')).toHaveCount(0); +}; + +test.describe('document editor', () => { + test('CC recipient added mid-list is displayed last with no signing order input', async ({ page }) => { + const surface = await openDocumentEnvelopeEditor(page); + const { root } = surface; + + await toggleSigningOrder(root, true); + + // Add signer A into the initial empty row. + await setRecipientEmail(root, 0, SIGNER_A.email); + await setRecipientName(root, 0, SIGNER_A.name); + + // Add the CC recipient second. + await root.getByRole('button', { name: 'Add Signer' }).click(); + await setRecipientEmail(root, 1, CC_RECIPIENT.email); + await setRecipientName(root, 1, CC_RECIPIENT.name); + await setRecipientRole(root, 1, 'Receives copy'); + + // Once the row becomes CC, its signing order input disappears. + await expect(getSigningOrderInputs(root)).toHaveCount(1); + + // Add signer B third. The new row is inserted before the CC recipient, + // which is kept last by the client-side sorting. + await root.getByRole('button', { name: 'Add Signer' }).click(); + await expect(getRecipientEmailInputs(root).nth(2)).toHaveValue(CC_RECIPIENT.email); + + await setRecipientEmail(root, 1, SIGNER_B.email); + await setRecipientName(root, 1, SIGNER_B.name); + + await assertCcDisplayedLastWithNoOrderInput(root); + + // The editor autosaves with a debounce, poll the DB until all three + // recipients have been persisted before reloading the page. + await expect + .poll( + async () => { + const recipients = await prisma.recipient.findMany({ + where: { envelopeId: surface.envelopeId }, + }); + + return recipients.length; + }, + { timeout: 15_000 }, + ) + .toBe(3); + + // Reload the editor and assert the CC recipient is still displayed last. + await root.reload(); + await expect(root.getByRole('heading', { name: 'Recipients' })).toBeVisible(); + + await assertCcDisplayedLastWithNoOrderInput(root); + }); + + test('CC recipient seeded with mid-list signing order is displayed last', async ({ page }) => { + const { user, team } = await seedUser(); + + const document = await seedBlankDocument(user, team.id, { + internalVersion: 2, + }); + + // Seed a CC recipient directly in the DB with a mid-list signing order + // (2 of 3) BEFORE opening the editor, so the editor's autosave cannot + // race with the seeded recipients, and assert the editor renders it last. + await prisma.envelope.update({ + where: { id: document.id }, + data: { + documentMeta: { + update: { signingOrder: DocumentSigningOrder.SEQUENTIAL }, + }, + recipients: { + createMany: { + data: [ + { + email: SIGNER_A.email, + name: SIGNER_A.name, + token: nanoid(), + role: RecipientRole.SIGNER, + signingOrder: 1, + }, + { + email: CC_RECIPIENT.email, + name: CC_RECIPIENT.name, + token: nanoid(), + role: RecipientRole.CC, + signingOrder: 2, + }, + { + email: SIGNER_B.email, + name: SIGNER_B.name, + token: nanoid(), + role: RecipientRole.SIGNER, + signingOrder: 3, + }, + ], + }, + }, + }, + }); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents/${document.id}/edit?step=uploadAndRecipients`, + }); + + await expect(page.getByRole('heading', { name: 'Recipients' })).toBeVisible(); + + await assertCcDisplayedLastWithNoOrderInput(page); + }); +}); diff --git a/packages/app-tests/e2e/teams/default-recipients.spec.ts b/packages/app-tests/e2e/teams/default-recipients.spec.ts index f8aa0d539..49acab5dc 100644 --- a/packages/app-tests/e2e/teams/default-recipients.spec.ts +++ b/packages/app-tests/e2e/teams/default-recipients.spec.ts @@ -110,7 +110,7 @@ test.describe('Default Recipients', () => { await page.getByRole('button', { name: 'Add Signer' }).click(); // Add a regular signer using the v2 editor - await page.getByTestId('signer-email-input').last().fill('regular-signer@documenso.com'); + await page.getByTestId('signer-email-input').first().fill('regular-signer@documenso.com'); await page .getByPlaceholder(/Recipient/) .first() diff --git a/packages/lib/client-only/hooks/use-editor-recipients.ts b/packages/lib/client-only/hooks/use-editor-recipients.ts index c4910b6ac..b1fce27e0 100644 --- a/packages/lib/client-only/hooks/use-editor-recipients.ts +++ b/packages/lib/client-only/hooks/use-editor-recipients.ts @@ -7,9 +7,10 @@ import { DocumentSigningOrder, RecipientRole } from '@prisma/client'; import { useId } from 'react'; import type { UseFormReturn } from 'react-hook-form'; import { useForm } from 'react-hook-form'; -import { prop, sortBy } from 'remeda'; import { z } from 'zod'; +import { isCcRecipient, normalizeRecipientSigningOrders, sortRecipientsForSigningOrder } from '../../utils/recipients'; + const LocalRecipientSchema = z.object({ formId: z.string().min(1), id: z.number().optional(), @@ -94,13 +95,13 @@ export const useEditorRecipients = ({ envelope }: EditorRecipientsProps): UseEdi name: recipient.name, email: recipient.email, role: recipient.role, - signingOrder: recipient.signingOrder ?? index + 1, + signingOrder: isCcRecipient(recipient) ? undefined : (recipient.signingOrder ?? index + 1), actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined, })); const signers: TLocalRecipient[] = formRecipients.length > 0 - ? sortBy(formRecipients, [prop('signingOrder'), 'asc'], [prop('id'), 'asc']) + ? normalizeRecipientSigningOrders(sortRecipientsForSigningOrder(formRecipients)) : [ { formId: initialId, diff --git a/packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts b/packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts index b178f4705..116238f3b 100644 --- a/packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts +++ b/packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts @@ -5,7 +5,7 @@ import EnvelopeSchema from '@documenso/prisma/generated/zod/modelSchema/Envelope import SignatureSchema from '@documenso/prisma/generated/zod/modelSchema/SignatureSchema'; import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema'; import UserSchema from '@documenso/prisma/generated/zod/modelSchema/UserSchema'; -import { DocumentSigningOrder, DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client'; +import { DocumentSigningOrder, DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client'; import { z } from 'zod'; import { AppError, AppErrorCode } from '../../errors/app-error'; @@ -266,6 +266,11 @@ export const getEnvelopeForRecipientSigning = async ({ if (envelope.documentMeta.signingOrder === DocumentSigningOrder.SEQUENTIAL && currentRecipientIndex !== -1) { for (let i = 0; i < currentRecipientIndex; i++) { + // CC recipients have no action to take, so they can never block the flow. + if (envelope.recipients[i].role === RecipientRole.CC) { + continue; + } + if (envelope.recipients[i].signingStatus !== SigningStatus.SIGNED) { isRecipientsTurn = false; break; diff --git a/packages/lib/server-only/recipient/get-is-recipient-turn.ts b/packages/lib/server-only/recipient/get-is-recipient-turn.ts index 8b4a6c85e..fae6e130b 100644 --- a/packages/lib/server-only/recipient/get-is-recipient-turn.ts +++ b/packages/lib/server-only/recipient/get-is-recipient-turn.ts @@ -1,5 +1,5 @@ import { prisma } from '@documenso/prisma'; -import { DocumentSigningOrder, EnvelopeType, SigningStatus } from '@prisma/client'; +import { DocumentSigningOrder, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client'; export type GetIsRecipientTurnOptions = { token: string; @@ -38,6 +38,11 @@ export async function getIsRecipientsTurnToSign({ token }: GetIsRecipientTurnOpt } for (let i = 0; i < currentRecipientIndex; i++) { + // CC recipients have no action to take, so they can never block the flow. + if (recipients[i].role === RecipientRole.CC) { + continue; + } + if (recipients[i].signingStatus !== SigningStatus.SIGNED) { return false; } diff --git a/packages/lib/server-only/recipient/get-next-pending-recipient.ts b/packages/lib/server-only/recipient/get-next-pending-recipient.ts index 6ead7d695..30e867691 100644 --- a/packages/lib/server-only/recipient/get-next-pending-recipient.ts +++ b/packages/lib/server-only/recipient/get-next-pending-recipient.ts @@ -1,5 +1,5 @@ import { prisma } from '@documenso/prisma'; -import { EnvelopeType } from '@prisma/client'; +import { EnvelopeType, RecipientRole } from '@prisma/client'; import { mapDocumentIdToSecondaryId } from '../../utils/envelope'; @@ -16,6 +16,11 @@ export const getNextPendingRecipient = async ({ type: EnvelopeType.DOCUMENT, secondaryId: mapDocumentIdToSecondaryId(documentId), }, + // CC recipients are informational only and never take part in signing, + // so they must never be offered as the next pending recipient. + role: { + not: RecipientRole.CC, + }, }, orderBy: [ { diff --git a/packages/lib/utils/recipients.test.ts b/packages/lib/utils/recipients.test.ts new file mode 100644 index 000000000..5d969f0ff --- /dev/null +++ b/packages/lib/utils/recipients.test.ts @@ -0,0 +1,71 @@ +import { RecipientRole } from '@prisma/client'; +import { describe, expect, it } from 'vitest'; + +import { isAssistantLastSigner, normalizeRecipientSigningOrders, sortRecipientsForSigningOrder } from './recipients'; + +describe('recipient signing order helpers', () => { + it('sorts CC recipients after ordered active recipients', () => { + const recipients = [ + { id: 1, role: RecipientRole.CC, signingOrder: 1 }, + { id: 2, role: RecipientRole.SIGNER, signingOrder: 2 }, + { id: 3, role: RecipientRole.APPROVER, signingOrder: 1 }, + ]; + + expect(sortRecipientsForSigningOrder(recipients).map((recipient) => recipient.id)).toEqual([3, 2, 1]); + }); + + it('keeps original order when recipients have the same signing order', () => { + const recipients = [ + { id: 2, role: RecipientRole.SIGNER, signingOrder: 1 }, + { id: 1, role: RecipientRole.APPROVER, signingOrder: 1 }, + ]; + + expect(sortRecipientsForSigningOrder(recipients).map((recipient) => recipient.id)).toEqual([2, 1]); + }); + + it('sorts and normalizes active recipient signing order and removes it from CC recipients', () => { + const recipients = [ + { id: 1, role: RecipientRole.CC, signingOrder: 1 }, + { id: 2, role: RecipientRole.SIGNER, signingOrder: 4 }, + { id: 3, role: RecipientRole.APPROVER, signingOrder: 2 }, + ]; + + expect(normalizeRecipientSigningOrders(sortRecipientsForSigningOrder(recipients))).toEqual([ + { id: 3, role: RecipientRole.APPROVER, signingOrder: 1 }, + { id: 2, role: RecipientRole.SIGNER, signingOrder: 2 }, + { id: 1, role: RecipientRole.CC, signingOrder: undefined }, + ]); + }); + + it('preserves caller order while normalizing signing order', () => { + const recipients = [ + { id: 2, role: RecipientRole.ASSISTANT, signingOrder: 2 }, + { id: 1, role: RecipientRole.SIGNER, signingOrder: 1 }, + { id: 3, role: RecipientRole.CC, signingOrder: 1 }, + ]; + + expect(normalizeRecipientSigningOrders(recipients)).toEqual([ + { id: 2, role: RecipientRole.ASSISTANT, signingOrder: 1 }, + { id: 1, role: RecipientRole.SIGNER, signingOrder: 2 }, + { id: 3, role: RecipientRole.CC, signingOrder: undefined }, + ]); + }); + + it('checks whether the last non-CC recipient is an assistant', () => { + expect( + isAssistantLastSigner([ + { role: RecipientRole.SIGNER }, + { role: RecipientRole.ASSISTANT }, + { role: RecipientRole.CC }, + ]), + ).toBe(true); + + expect( + isAssistantLastSigner([ + { role: RecipientRole.ASSISTANT }, + { role: RecipientRole.SIGNER }, + { role: RecipientRole.CC }, + ]), + ).toBe(false); + }); +}); diff --git a/packages/lib/utils/recipients.ts b/packages/lib/utils/recipients.ts index 6da161568..f51d02c78 100644 --- a/packages/lib/utils/recipients.ts +++ b/packages/lib/utils/recipients.ts @@ -1,6 +1,6 @@ import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field'; -import type { Envelope } from '@prisma/client'; -import { type Field, RecipientRole, SigningStatus } from '@prisma/client'; +import type { Envelope, Field, Recipient } from '@prisma/client'; +import { RecipientRole, SigningStatus } from '@prisma/client'; import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app'; import { AppError, AppErrorCode } from '../errors/app-error'; @@ -15,6 +15,58 @@ import { zEmail } from './zod'; */ export const RECIPIENT_ROLES_THAT_REQUIRE_FIELDS = [RecipientRole.SIGNER] as const; +// signingOrder isn't required when submitting the recipient form (Zod: z.number().optional()) +type RecipientWithSigningOrder = Pick & Partial>; + +export const isCcRecipient = (recipient: Pick) => { + return recipient.role === RecipientRole.CC; +}; + +export const isAssistantLastSigner = (recipients: Pick[]) => { + const nonCcRecipients = recipients.filter((recipient) => !isCcRecipient(recipient)); + const lastNonCcRecipient = nonCcRecipients[nonCcRecipients.length - 1]; + + return lastNonCcRecipient?.role === RecipientRole.ASSISTANT; +}; + +export const sortRecipientsForSigningOrder = (recipients: T[]): T[] => { + return [...recipients].sort((r1, r2) => { + const r1IsCcRecipient = isCcRecipient(r1); + const r2IsCcRecipient = isCcRecipient(r2); + + // CC recipients always sort after non-CC recipients. + if (r1IsCcRecipient !== r2IsCcRecipient) { + return r1IsCcRecipient ? 1 : -1; + } + + // Order by signing order; missing orders sort last. + const r1SigningOrder = r1.signingOrder ?? Number.MAX_SAFE_INTEGER; + const r2SigningOrder = r2.signingOrder ?? Number.MAX_SAFE_INTEGER; + + return r1SigningOrder - r2SigningOrder; + }); +}; + +export const normalizeRecipientSigningOrders = ( + recipients: T[], + canUpdateRecipient: (recipient: T) => boolean = () => true, +): Array => { + const nonCcRecipients = recipients.filter((recipient) => !isCcRecipient(recipient)); + const ccRecipients = recipients.filter((recipient) => isCcRecipient(recipient)); + + const normalizedNonCcRecipients = nonCcRecipients.map((recipient, index) => ({ + ...recipient, + signingOrder: canUpdateRecipient(recipient) ? index + 1 : (recipient.signingOrder ?? index + 1), + })); + + const normalizedCcRecipients = ccRecipients.map((recipient) => ({ + ...recipient, + signingOrder: undefined, + })); + + return [...normalizedNonCcRecipients, ...normalizedCcRecipients]; +}; + /** * Returns recipients who are missing required fields for their role. * diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index 9c77f16e2..27dc32678 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -6,7 +6,13 @@ import { useSession } from '@documenso/lib/client-only/providers/session'; import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; import type { TRecipientLite } from '@documenso/lib/types/recipient'; import { nanoid } from '@documenso/lib/universal/id'; -import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients'; +import { + isAssistantLastSigner, + isCcRecipient, + normalizeRecipientSigningOrders, + sortRecipientsForSigningOrder, + canRecipientBeModified as utilCanRecipientBeModified, +} from '@documenso/lib/utils/recipients'; import { trpc } from '@documenso/trpc/react'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select'; @@ -24,7 +30,6 @@ import { motion } from 'framer-motion'; import { GripVerticalIcon, HelpCircle, Plus, Trash } from 'lucide-react'; import { useCallback, useId, useMemo, useRef, useState } from 'react'; import { useFieldArray, useForm } from 'react-hook-form'; -import { prop, sortBy } from 'remeda'; import { DocumentReadOnlyFields, mapFieldsWithRecipients } from '../../components/document/document-read-only-fields'; import type { RecipientAutoCompleteOption } from '../../components/recipient/recipient-autocomplete-input'; @@ -118,18 +123,18 @@ export const AddSignersFormPartial = ({ defaultValues: { signers: recipients.length > 0 - ? sortBy( - recipients.map((recipient, index) => ({ - nativeId: recipient.id, - formId: String(recipient.id), - name: recipient.name, - email: recipient.email, - role: recipient.role, - signingOrder: recipient.signingOrder ?? index + 1, - actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined, - })), - [prop('signingOrder'), 'asc'], - [prop('nativeId'), 'asc'], + ? normalizeRecipientSigningOrders( + sortRecipientsForSigningOrder( + recipients.map((recipient, index) => ({ + nativeId: recipient.id, + formId: String(recipient.id), + name: recipient.name, + email: recipient.email, + role: recipient.role, + signingOrder: isCcRecipient(recipient) ? undefined : (recipient.signingOrder ?? index + 1), + actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined, + })), + ), ) : defaultRecipients, signingOrder: signingOrder || DocumentSigningOrder.PARALLEL, @@ -168,18 +173,14 @@ export const AddSignersFormPartial = ({ }, [watchedSigners]); const normalizeSigningOrders = (signers: typeof watchedSigners) => { - return signers - .sort((a, b) => (a.signingOrder ?? 0) - (b.signingOrder ?? 0)) - .map((signer, index) => ({ ...signer, signingOrder: index + 1 })); + return normalizeRecipientSigningOrders(signers, (signer) => canRecipientBeModified(signer.nativeId)); }; + const activeRecipientCount = watchedSigners.filter((signer) => !isCcRecipient(signer)).length; + const onFormSubmit = form.handleSubmit(onSubmit); - const { - append: appendSigner, - fields: signers, - remove: removeSigner, - } = useFieldArray({ + const { fields: signers, remove: removeSigner } = useFieldArray({ control, name: 'signers', }); @@ -258,14 +259,31 @@ export const AddSignersFormPartial = ({ return utilCanRecipientBeModified(recipient, fields); }; + const appendNormalizedSigner = (signer: (typeof watchedSigners)[number], shouldFocus = false) => { + const updatedSigners = normalizeSigningOrders([...form.getValues('signers'), signer]); + + form.setValue('signers', updatedSigners, { + shouldValidate: true, + shouldDirty: true, + }); + + if (shouldFocus) { + const signerIndex = updatedSigners.findIndex((updatedSigner) => updatedSigner.formId === signer.formId); + + if (signerIndex !== -1) { + requestAnimationFrame(() => form.setFocus(`signers.${signerIndex}.email`)); + } + } + }; + const onAddSigner = () => { - appendSigner({ + appendNormalizedSigner({ formId: nanoid(12), name: '', email: '', role: RecipientRole.SIGNER, actionAuth: [], - signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1, + signingOrder: activeRecipientCount + 1, }); }; @@ -310,18 +328,16 @@ export const AddSignersFormPartial = ({ form.setFocus(`signers.${emptySignerIndex}.email`); } else { - appendSigner( + appendNormalizedSigner( { formId: nanoid(12), name: user?.name ?? '', email: user?.email ?? '', role: RecipientRole.SIGNER, actionAuth: [], - signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1, - }, - { - shouldFocus: true, + signingOrder: activeRecipientCount + 1, }, + true, ); void form.trigger('signers'); @@ -356,18 +372,14 @@ export const AddSignersFormPartial = ({ items.splice(insertIndex, 0, reorderedSigner); - const updatedSigners = items.map((signer, index) => ({ - ...signer, - signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : index + 1, - })); + const updatedSigners = normalizeSigningOrders(items); form.setValue('signers', updatedSigners, { shouldValidate: true, shouldDirty: true, }); - const lastSigner = updatedSigners[updatedSigners.length - 1]; - if (lastSigner.role === RecipientRole.ASSISTANT) { + if (isAssistantLastSigner(updatedSigners)) { toast({ title: _(msg`Warning: Assistant as last signer`), description: _( @@ -402,18 +414,19 @@ export const AddSignersFormPartial = ({ return; } - const updatedSigners = currentSigners.map((signer, idx) => ({ - ...signer, - role: idx === index ? role : signer.role, - signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : idx + 1, - })); + const updatedSigners = normalizeSigningOrders( + currentSigners.map((signer, idx) => ({ + ...signer, + role: idx === index ? role : signer.role, + })), + ); form.setValue('signers', updatedSigners, { shouldValidate: true, shouldDirty: true, }); - if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) { + if (role === RecipientRole.ASSISTANT && isAssistantLastSigner(updatedSigners)) { toast({ title: _(msg`Warning: Assistant as last signer`), description: _( @@ -440,22 +453,30 @@ export const AddSignersFormPartial = ({ const currentSigners = form.getValues('signers'); const signer = currentSigners[index]; - // Remove signer from current position and insert at new position - const remainingSigners = currentSigners.filter((_, idx) => idx !== index); - const newPosition = Math.min(Math.max(0, newOrder - 1), currentSigners.length - 1); - remainingSigners.splice(newPosition, 0, signer); + if (isCcRecipient(signer)) { + return; + } - const updatedSigners = remainingSigners.map((s, idx) => ({ - ...s, - signingOrder: !canRecipientBeModified(s.nativeId) ? s.signingOrder : idx + 1, - })); + const nonCcSigners = currentSigners.filter((s) => !isCcRecipient(s)); + const ccSigners = currentSigners.filter((s) => isCcRecipient(s)); + const currentSigningOrderIndex = nonCcSigners.findIndex((s) => s.formId === signer.formId); + + if (currentSigningOrderIndex === -1) { + return; + } + + const [reorderedSigner] = nonCcSigners.splice(currentSigningOrderIndex, 1); + const newPosition = Math.min(Math.max(0, newOrder - 1), nonCcSigners.length); + nonCcSigners.splice(newPosition, 0, reorderedSigner); + + const updatedSigners = normalizeSigningOrders([...nonCcSigners, ...ccSigners]); form.setValue('signers', updatedSigners, { shouldValidate: true, shouldDirty: true, }); - if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) { + if (signer.role === RecipientRole.ASSISTANT && isAssistantLastSigner(updatedSigners)) { toast({ title: _(msg`Warning: Assistant as last signer`), description: _( @@ -471,10 +492,12 @@ export const AddSignersFormPartial = ({ setShowSigningOrderConfirmation(false); const currentSigners = form.getValues('signers'); - const updatedSigners = currentSigners.map((signer) => ({ - ...signer, - role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role, - })); + const updatedSigners = normalizeSigningOrders( + currentSigners.map((signer) => ({ + ...signer, + role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role, + })), + ); form.setValue('signers', updatedSigners, { shouldValidate: true, @@ -642,6 +665,7 @@ export const AddSignersFormPartial = ({ isDragDisabled={ !isSigningOrderSequential || isSubmitting || + isCcRecipient(signer) || !canRecipientBeModified(signer.nativeId) || !signer.signingOrder } @@ -663,7 +687,9 @@ export const AddSignersFormPartial = ({ 'grid-cols-12 pr-3': isSigningOrderSequential, })} > - {isSigningOrderSequential && ( + {isSigningOrderSequential && isCcRecipient(signer) &&
} + + {isSigningOrderSequential && !isCcRecipient(signer) && (