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) && (