From ba0dead96ff7010a9e954e43da585a5a37a78af2 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 20 Jul 2026 15:57:38 +0900 Subject: [PATCH 01/27] fix: render error messages for invalid templates (#3088) Currently direct templates can be created without the required signatures fields for signers. This means that the document can be fully signed by everyone but will ultimately fail the sealing step which leaves the document in an unrecoverable state. --- .../direct-template-invalid-page.tsx | 23 ++++ .../envelope-editor-fields-page.tsx | 3 + ...e-editor-invalid-direct-template-alert.tsx | 55 ++++++++ .../envelope-editor-preview-page.tsx | 3 + .../envelope-editor-upload-page.tsx | 4 + .../routes/_recipient+/d.$token+/_index.tsx | 28 ++++ apps/remix/app/utils/toast-error-messages.ts | 8 ++ ...nvelope-direct-template-validation.spec.ts | 76 +++++++++++ .../public-profiles/public-profiles.spec.ts | 12 ++ .../e2e/templates/direct-templates.spec.ts | 125 +++++++++++++++--- .../e2e/templates/template-use-dialog.spec.ts | 101 ++++++++++++++ packages/lib/errors/app-error.ts | 9 ++ .../lib/server-only/document/send-document.ts | 2 +- ...et-envelope-for-direct-template-signing.ts | 12 ++ .../create-document-from-direct-template.ts | 12 ++ packages/prisma/seed/initial-seed.ts | 6 +- packages/prisma/seed/templates.ts | 38 +++++- 17 files changed, 490 insertions(+), 27 deletions(-) create mode 100644 apps/remix/app/components/general/direct-template/direct-template-invalid-page.tsx create mode 100644 apps/remix/app/components/general/envelope-editor/envelope-editor-invalid-direct-template-alert.tsx create mode 100644 packages/app-tests/e2e/envelope-editor-v2/envelope-direct-template-validation.spec.ts create mode 100644 packages/app-tests/e2e/templates/template-use-dialog.spec.ts diff --git a/apps/remix/app/components/general/direct-template/direct-template-invalid-page.tsx b/apps/remix/app/components/general/direct-template/direct-template-invalid-page.tsx new file mode 100644 index 000000000..c95b4109f --- /dev/null +++ b/apps/remix/app/components/general/direct-template/direct-template-invalid-page.tsx @@ -0,0 +1,23 @@ +import { Trans } from '@lingui/react/macro'; +import { AlertTriangleIcon } from 'lucide-react'; + +export const DirectTemplateInvalidPageView = () => { + return ( +
+
+ + +

+ Invalid direct link template +

+ +

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

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

+ Once confirmed, the following will be reset: +

+ +
    + {showDocumentVisibility && ( +
  • + Default document visibility +
  • + )} +
  • + Default document language +
  • +
  • + Default date format +
  • +
  • + Default time zone +
  • +
  • + Default signature settings +
  • + {showIncludeSenderDetails && ( +
  • + Send on behalf of team +
  • + )} +
  • + Include the signing certificate in the document +
  • +
  • + Include the audit logs in the document +
  • +
  • + Default recipients +
  • +
  • + Delegate document ownership +
  • +
  • + Default envelope expiration +
  • +
  • + Default signing reminders +
  • + {showAiFeatures && ( +
  • + AI features +
  • + )} +
+
+
+ + + + + + + + +
+
+ ); +}; diff --git a/apps/remix/app/components/forms/document-preferences-form.tsx b/apps/remix/app/components/forms/document-preferences-form.tsx index f1a0b6d8a..cdf39eb51 100644 --- a/apps/remix/app/components/forms/document-preferences-form.tsx +++ b/apps/remix/app/components/forms/document-preferences-form.tsx @@ -11,10 +11,10 @@ import { isValidLanguageCode, SUPPORTED_LANGUAGE_CODES, SUPPORTED_LANGUAGES } fr import { TIME_ZONES } from '@documenso/lib/constants/time-zones'; import type { TDefaultRecipients } from '@documenso/lib/types/default-recipients'; import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients'; -import { type TDocumentMetaDateFormat, ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta'; -import { isPersonalLayout } from '@documenso/lib/utils/organisations'; +import { type TDocumentMetaDateFormat, ZDocumentMetaDateFormatSchema } from '@documenso/lib/types/document-meta'; +import { generateDefaultOrganisationSettings, isPersonalLayout } from '@documenso/lib/utils/organisations'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; -import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams'; +import { extractTeamSignatureSettings, generateDefaultTeamSettings } from '@documenso/lib/utils/teams'; import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip'; import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker'; import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker'; @@ -37,11 +37,11 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { msg, t } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { TeamGlobalSettings } from '@prisma/client'; -import { DocumentVisibility, OrganisationType, type RecipientRole } from '@prisma/client'; +import { DocumentVisibility, OrganisationType, type RecipientRole, type TeamGlobalSettings } from '@prisma/client'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; +import { DocumentPreferencesResetDialog } from '~/components/dialogs/document-preferences-reset-dialog'; import { useOptionalCurrentTeam } from '~/providers/team'; import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox'; @@ -93,6 +93,26 @@ export type DocumentPreferencesFormProps = { onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise; }; +const getDocumentPreferencesFormValues = (settings: SettingsSubset): TDocumentPreferencesFormSchema => { + const parsedDocumentDateFormat = ZDocumentMetaDateFormatSchema.safeParse(settings.documentDateFormat); + + return { + documentVisibility: settings.documentVisibility, + documentLanguage: isValidLanguageCode(settings.documentLanguage) ? settings.documentLanguage : null, + documentTimezone: settings.documentTimezone, + documentDateFormat: parsedDocumentDateFormat.success ? parsedDocumentDateFormat.data : null, + includeSenderDetails: settings.includeSenderDetails, + includeSigningCertificate: settings.includeSigningCertificate, + includeAuditLog: settings.includeAuditLog, + signatureTypes: extractTeamSignatureSettings({ ...settings }), + defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null, + delegateDocumentOwnership: settings.delegateDocumentOwnership, + aiFeaturesEnabled: settings.aiFeaturesEnabled, + envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null, + reminderSettings: settings.reminderSettings ?? null, + }; +}; + export const DocumentPreferencesForm = ({ settings, onFormSubmit, @@ -113,7 +133,7 @@ export const DocumentPreferencesForm = ({ documentVisibility: z.nativeEnum(DocumentVisibility).nullable(), documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).nullable(), documentTimezone: z.string().nullable(), - documentDateFormat: ZDocumentMetaTimezoneSchema.nullable(), + documentDateFormat: ZDocumentMetaDateFormatSchema.nullable(), includeSenderDetails: z.boolean().nullable(), includeSigningCertificate: z.boolean().nullable(), includeAuditLog: z.boolean().nullable(), @@ -127,26 +147,33 @@ export const DocumentPreferencesForm = ({ reminderSettings: ZEnvelopeReminderSettings.nullable(), }); + const defaultValues = getDocumentPreferencesFormValues(settings); + const defaultSettings = canInherit ? generateDefaultTeamSettings() : generateDefaultOrganisationSettings(); + const baseResetValues = getDocumentPreferencesFormValues(defaultSettings); + const resetValues = { + ...baseResetValues, + aiFeaturesEnabled: isAiFeaturesConfigured ? baseResetValues.aiFeaturesEnabled : defaultValues.aiFeaturesEnabled, + }; + const form = useForm({ - defaultValues: { - documentVisibility: settings.documentVisibility, - documentLanguage: isValidLanguageCode(settings.documentLanguage) ? settings.documentLanguage : null, - documentTimezone: settings.documentTimezone, - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - documentDateFormat: settings.documentDateFormat as TDocumentMetaDateFormat | null, - includeSenderDetails: settings.includeSenderDetails, - includeSigningCertificate: settings.includeSigningCertificate, - includeAuditLog: settings.includeAuditLog, - signatureTypes: extractTeamSignatureSettings({ ...settings }), - defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null, - delegateDocumentOwnership: settings.delegateDocumentOwnership, - aiFeaturesEnabled: settings.aiFeaturesEnabled, - envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null, - reminderSettings: settings.reminderSettings ?? null, - }, + defaultValues, resolver: zodResolver(ZDocumentPreferencesFormSchema), }); + // Parse both sides through the schema so we compare canonical representations + const parsedCurrentValues = ZDocumentPreferencesFormSchema.safeParse(defaultValues); + const parsedResetValues = ZDocumentPreferencesFormSchema.safeParse(resetValues); + + const isResetToDefaultsVisible = + !parsedCurrentValues.success || + !parsedResetValues.success || + JSON.stringify(parsedCurrentValues.data) !== JSON.stringify(parsedResetValues.data); + + const handleResetToDefaults = async () => { + await onFormSubmit(resetValues); + form.reset(resetValues); + }; + const handleFormSubmit = form.handleSubmit(async (data) => { try { await onFormSubmit(data); @@ -772,6 +799,17 @@ export const DocumentPreferencesForm = ({ isDirty={form.formState.isDirty} isSubmitting={form.formState.isSubmitting} onReset={() => form.reset()} + resetToDefaults={ + isResetToDefaultsVisible ? ( + + ) : undefined + } /> diff --git a/apps/remix/app/components/forms/form-sticky-save-bar.tsx b/apps/remix/app/components/forms/form-sticky-save-bar.tsx index 1d37f9482..addc155e7 100644 --- a/apps/remix/app/components/forms/form-sticky-save-bar.tsx +++ b/apps/remix/app/components/forms/form-sticky-save-bar.tsx @@ -3,12 +3,17 @@ import { Button } from '@documenso/ui/primitives/button'; import { Trans, useLingui } from '@lingui/react/macro'; import { AnimatePresence, motion } from 'framer-motion'; import { AlertTriangleIcon } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; +import { type ReactNode, useEffect, useRef, useState } from 'react'; export type FormStickySaveBarProps = { isDirty: boolean; isSubmitting: boolean; onReset: () => void; + /** + * Slot for a "reset to defaults" action, rendered before the Undo button. Hidden while + * the bar is floating so it never appears in the unsaved-changes island. + */ + resetToDefaults?: ReactNode; }; /** @@ -24,7 +29,7 @@ export type FormStickySaveBarProps = { * shared-layout morph). A 1px sentinel below it detects the stuck state so we can toggle * the pill chrome. */ -export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset }: FormStickySaveBarProps) => { +export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset, resetToDefaults }: FormStickySaveBarProps) => { const { t } = useLingui(); const sentinelRef = useRef(null); @@ -100,6 +105,8 @@ export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset }: FormSticky
+ {!isFloating && resetToDefaults} + {isDirty && ( + )} + + + + + + Reset branding preferences + + + + + This will reset all branding preferences to their default values and save the changes immediately. + + + + + + +

+ Once confirmed, the following will be reset: +

+ +
    +
  • + Custom branding enabled setting +
  • +
  • + Branding logo +
  • +
  • + Brand website and brand details +
  • +
  • + Brand colours, including background, foreground, primary, and border colours +
  • + + {hasAdvancedBranding && ( + <> +
  • + Border radius +
  • +
  • + Custom CSS +
  • + + )} +
+
+
+ + + + + + + + +
+ + ); +}; diff --git a/apps/remix/app/components/forms/branding-preferences-form.tsx b/apps/remix/app/components/forms/branding-preferences-form.tsx index ef3ff6b34..e556ff6cf 100644 --- a/apps/remix/app/components/forms/branding-preferences-form.tsx +++ b/apps/remix/app/components/forms/branding-preferences-form.tsx @@ -7,6 +7,7 @@ import { } from '@documenso/lib/constants/branding'; import { DEFAULT_BRAND_COLORS, DEFAULT_BRAND_RADIUS } from '@documenso/lib/constants/theme'; import { ZCssVarsSchema } from '@documenso/lib/types/css-vars'; +import { normalizeBrandingColors } from '@documenso/lib/utils/normalize-branding-colors'; import { cn } from '@documenso/ui/lib/utils'; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion'; import { Button } from '@documenso/ui/primitives/button'; @@ -23,6 +24,7 @@ import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; +import { BrandingPreferencesResetDialog } from '~/components/dialogs/branding-preferences-reset-dialog'; import { useOptionalCurrentTeam } from '~/providers/team'; import { useCspNonce } from '~/utils/nonce'; @@ -74,6 +76,7 @@ export function BrandingPreferencesForm({ const [previewUrl, setPreviewUrl] = useState(''); const [hasLoadedPreview, setHasLoadedPreview] = useState(false); + const [colorPickerKey, setColorPickerKey] = useState(0); const parsedColors = ZCssVarsSchema.safeParse(settings.brandingColors); const initialColors = parsedColors.success ? parsedColors.data : {}; @@ -96,6 +99,42 @@ export function BrandingPreferencesForm({ const isBrandingEnabled = form.watch('brandingEnabled'); + const hasResetBrandingColors = + settings.brandingColors === null || + settings.brandingColors === undefined || + (parsedColors.success && normalizeBrandingColors(parsedColors.data) === null); + + // Only show the reset action when the saved settings actually differ from the + // defaults, so it never renders as a pointless disabled button. + const isResetToDefaultsVisible = + settings.brandingEnabled !== (canInherit ? null : false) || + !!settings.brandingLogo || + !!settings.brandingUrl || + !!settings.brandingCompanyDetails || + !!settings.brandingCss || + !hasResetBrandingColors; + + const handleResetToDefaults = async () => { + const data: TBrandingPreferencesFormSchema = { + brandingEnabled: canInherit ? null : false, + brandingLogo: null, + brandingUrl: '', + brandingCompanyDetails: '', + brandingColors: {}, + brandingCss: '', + }; + + await onFormSubmit(data); + + if (previewUrl.startsWith('blob:')) { + URL.revokeObjectURL(previewUrl); + } + + setPreviewUrl(''); + setColorPickerKey((key) => key + 1); + form.reset(data); + }; + const getSavedLogoPreviewUrl = () => { if (!settings.brandingLogo) { return ''; @@ -397,6 +436,7 @@ export function BrandingPreferencesForm({ + ) : undefined + } /> From 3cf2963cd03d8b24770b7490bdb20e596baa5d65 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Tue, 21 Jul 2026 15:06:36 +1000 Subject: [PATCH 04/27] v2.16.0 --- apps/remix/package.json | 2 +- package-lock.json | 6 +++--- package.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/remix/package.json b/apps/remix/package.json index 1ea895f2c..0a889ccef 100644 --- a/apps/remix/package.json +++ b/apps/remix/package.json @@ -106,5 +106,5 @@ "vite-plugin-babel-macros": "^1.0.6", "vite-tsconfig-paths": "^5.1.4" }, - "version": "2.15.0" + "version": "2.16.0" } diff --git a/package-lock.json b/package-lock.json index 3e984b050..0109d8c0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@documenso/root", - "version": "2.15.0", + "version": "2.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@documenso/root", - "version": "2.15.0", + "version": "2.16.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -366,7 +366,7 @@ }, "apps/remix": { "name": "@documenso/remix", - "version": "2.15.0", + "version": "2.16.0", "dependencies": { "@cantoo/pdf-lib": "^2.5.3", "@documenso/api": "*", diff --git a/package.json b/package.json index 984663e94..97b1b0811 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "apps/*", "packages/*" ], - "version": "2.15.0", + "version": "2.16.0", "scripts": { "postinstall": "patch-package", "build": "turbo run build", From 7f85388eb729370a2223bc52291a25ab2b737de5 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Tue, 21 Jul 2026 15:58:36 +1000 Subject: [PATCH 05/27] fix: increase global API rate limits to 1000/min (#3081) --- apps/docs/content/docs/developers/api/rate-limits.mdx | 7 ++++++- .../content/docs/developers/examples/common-workflows.mdx | 6 +++--- .../docs/developers/getting-started/first-api-call.mdx | 2 +- apps/docs/content/docs/policies/fair-use.mdx | 7 ++++++- .../self-hosting/configuration/organisation-limits.mdx | 4 ++-- .../app-tests/e2e/api/v1/organisation-rate-limits.spec.ts | 6 +++--- .../app-tests/e2e/api/v2/organisation-rate-limits.spec.ts | 4 ++-- packages/lib/server-only/rate-limit/rate-limits.ts | 4 ++-- 8 files changed, 25 insertions(+), 15 deletions(-) diff --git a/apps/docs/content/docs/developers/api/rate-limits.mdx b/apps/docs/content/docs/developers/api/rate-limits.mdx index d2e31b1d4..95b0a68fe 100644 --- a/apps/docs/content/docs/developers/api/rate-limits.mdx +++ b/apps/docs/content/docs/developers/api/rate-limits.mdx @@ -11,9 +11,14 @@ Documenso enforces rate limits on all API endpoints to ensure service stability. ## HTTP Rate Limits -**Limit:** 100 requests per minute per IP address +**Limit:** 1000 requests per minute per IP address **Response:** 429 Too Many Requests + + This is the global per-IP ceiling. Your organisation may have its own rate limits configured below + this value, in which case you can be rate-limited before reaching the global limit. + + ### Rate Limit Response ```json diff --git a/apps/docs/content/docs/developers/examples/common-workflows.mdx b/apps/docs/content/docs/developers/examples/common-workflows.mdx index fe7887d5b..e0447d7c4 100644 --- a/apps/docs/content/docs/developers/examples/common-workflows.mdx +++ b/apps/docs/content/docs/developers/examples/common-workflows.mdx @@ -472,7 +472,7 @@ Send the same document to multiple recipients in parallel. Useful for policy ack distributeDocument: true - Process in batches with a short delay to respect rate limits (e.g. 100 requests/minute) + Process in batches with a short delay to respect rate limits (e.g. 1000 requests/minute) @@ -638,8 +638,8 @@ done - The API allows 100 requests per minute. For large batches, implement rate limiting with delays - between requests to avoid hitting limits. + The API allows 1000 requests per minute (your organisation may have its own lower limit). For large + batches, implement rate limiting with delays between requests to avoid hitting limits. --- diff --git a/apps/docs/content/docs/developers/getting-started/first-api-call.mdx b/apps/docs/content/docs/developers/getting-started/first-api-call.mdx index 5ae0a6c67..665a90afd 100644 --- a/apps/docs/content/docs/developers/getting-started/first-api-call.mdx +++ b/apps/docs/content/docs/developers/getting-started/first-api-call.mdx @@ -483,7 +483,7 @@ The API returns standard HTTP status codes and JSON error responses: ### Handling Rate Limits -The API allows 100 requests per minute per IP address. When rate limited, wait at least 60 seconds before retrying: +The API allows 1000 requests per minute per IP address. Your organisation may have its own lower rate limits. When rate limited, wait at least 60 seconds before retrying: ```javascript async function fetchWithRetry(url, options, maxRetries = 3) { diff --git a/apps/docs/content/docs/policies/fair-use.mdx b/apps/docs/content/docs/policies/fair-use.mdx index 0c4de348d..98d94dd5f 100644 --- a/apps/docs/content/docs/policies/fair-use.mdx +++ b/apps/docs/content/docs/policies/fair-use.mdx @@ -41,12 +41,17 @@ When a limit is reached, requests return a `429 Too Many Requests` response with | Action | Limit | Window | | --- | --- | --- | -| API requests (v1 and v2) | 100 requests | 1 minute | +| API requests (v1 and v2) | 1000 requests | 1 minute | | File uploads | 20 requests | 1 minute | | AI features | 3 requests | 1 minute | Authentication endpoints (login, signup, password reset, etc.) are also rate-limited to protect against abuse. + + The API request limit above is the global per-IP ceiling. Individual organisations also have their + own rate limits, which may be configured below this value. + + Rate limits may vary by plan. Enterprise plans can include higher or custom limits. Contact [sales](https://documen.so/sales) for details. diff --git a/apps/docs/content/docs/self-hosting/configuration/organisation-limits.mdx b/apps/docs/content/docs/self-hosting/configuration/organisation-limits.mdx index 2459812ce..c225975e5 100644 --- a/apps/docs/content/docs/self-hosting/configuration/organisation-limits.mdx +++ b/apps/docs/content/docs/self-hosting/configuration/organisation-limits.mdx @@ -13,7 +13,7 @@ There are three distinct kinds of limit: | ---------------------- | ------------------------------------------------- | ----------------------- | | Resource quota | Documents, emails, and API requests **per month** | Yes — per claim and org | | Resource rate limit | The same resources over a short window (e.g. `1h`) | Yes — per claim and org | -| Global HTTP rate limit | API requests per IP (100/min, hardcoded) | No — see [Limitations](#limitations) | +| Global HTTP rate limit | API requests per IP (1000/min, hardcoded) | No — see [Limitations](#limitations) | ## Prerequisites @@ -91,7 +91,7 @@ Monthly quota usage is keyed to the **UTC calendar month**. There is no schedule ## Limitations -The **global HTTP rate limit is not configurable.** Documenso enforces a hardcoded **100 requests per minute per IP address** on its API endpoint groups (`/api/v1`, `/api/v2`, and the tRPC API are limited separately), returning `429 Too Many Requests`. It is a per-IP safeguard applied at the HTTP layer — not per-organisation, not stored on any claim, and not adjustable from the admin panel. See [Rate Limits](/docs/developers/api/rate-limits). +The **global HTTP rate limit is not configurable.** Documenso enforces a hardcoded **1000 requests per minute per IP address** on its API endpoint groups (`/api/v1`, `/api/v2`, and the tRPC API are limited separately), returning `429 Too Many Requests`. It is a per-IP safeguard applied at the HTTP layer — not per-organisation, not stored on any claim, and not adjustable from the admin panel. See [Rate Limits](/docs/developers/api/rate-limits). ## Troubleshooting diff --git a/packages/app-tests/e2e/api/v1/organisation-rate-limits.spec.ts b/packages/app-tests/e2e/api/v1/organisation-rate-limits.spec.ts index d5e1fff6d..373f3738e 100644 --- a/packages/app-tests/e2e/api/v1/organisation-rate-limits.spec.ts +++ b/packages/app-tests/e2e/api/v1/organisation-rate-limits.spec.ts @@ -50,7 +50,7 @@ import type { Organisation, Team, User } from '@prisma/client'; * * --- GLOBAL LIMIT AWARENESS --- * apps/remix/server/router.ts applies a GLOBAL per-IP limiter to /api/v1/*: - * apiV1RateLimit = 100 requests / 1 minute (action `api.v1`, see rate-limits.ts). + * apiV1RateLimit = 1000 requests / 1 minute (action `api.v1`, see rate-limits.ts). * Every per-org limit/quota configured here is kept FAR below that ceiling (single * digits) and the suite runs serially so the shared-IP global bucket is never the * thing that trips. A global-limit 429 is shaped `{ error }` whereas an org-limit @@ -62,7 +62,7 @@ const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); const baseUrl = `${WEBAPP_BASE_URL}/api/v1`; // Run serially: all workers share one IP, and the global /api/v1 limiter is -// per-IP. Serial execution keeps the shared global bucket well under 100/min. +// per-IP. Serial execution keeps the shared global bucket well under 1000/min. test.describe.configure({ mode: 'serial' }); // This suite is only meaningful with real rate limiting enabled. CI sets the @@ -125,7 +125,7 @@ const setClaimLimits = async (team: Team, limits: ClaimLimits) => { * GLOBAL /api/v1 IP bucket so a fresh scenario starts from zero. * * - The org windowed limiter keys its rows `ip:org:`. - * - The GLOBAL limiter (apps/remix/server/router.ts -> apiV1RateLimit, 100/min + * - The GLOBAL limiter (apps/remix/server/router.ts -> apiV1RateLimit, 1000/min * per IP, action `api.v1`) is shared by EVERY v1 request from this test client. * Across the suite (and especially across repeated local runs within the same * minute) that shared bucket would otherwise fill up and trip BEFORE the org diff --git a/packages/app-tests/e2e/api/v2/organisation-rate-limits.spec.ts b/packages/app-tests/e2e/api/v2/organisation-rate-limits.spec.ts index df261eb08..d64ab28c2 100644 --- a/packages/app-tests/e2e/api/v2/organisation-rate-limits.spec.ts +++ b/packages/app-tests/e2e/api/v2/organisation-rate-limits.spec.ts @@ -37,7 +37,7 @@ import type { Organisation, Team, User } from '@prisma/client'; * * --- GLOBAL LIMIT AWARENESS --- * apps/remix/server/router.ts applies a GLOBAL per-IP limiter to /api/v2/*: - * apiV2RateLimit = 100 requests / 1 minute (see rate-limits.ts). + * apiV2RateLimit = 1000 requests / 1 minute (see rate-limits.ts). * Every per-org limit/quota configured here is kept FAR below that ceiling (single * digits) and the suite runs serially so the shared-IP global bucket is never the * thing that trips. A global-limit 429 is shaped `{ error }` whereas an org-limit @@ -49,7 +49,7 @@ const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`; // Run serially: all workers share one IP, and the global /api/v2 limiter is -// per-IP. Serial execution keeps the shared global bucket well under 100/min. +// per-IP. Serial execution keeps the shared global bucket well under 1000/min. test.describe.configure({ mode: 'serial' }); // This suite is only meaningful with real rate limiting enabled. CI sets the diff --git a/packages/lib/server-only/rate-limit/rate-limits.ts b/packages/lib/server-only/rate-limit/rate-limits.ts index 46233354c..5dfa47450 100644 --- a/packages/lib/server-only/rate-limit/rate-limits.ts +++ b/packages/lib/server-only/rate-limit/rate-limits.ts @@ -84,13 +84,13 @@ export const syncSubscriptionRateLimit = createRateLimit({ export const apiV1RateLimit = createRateLimit({ action: 'api.v1', - max: 100, + max: 1000, window: '1m', }); export const apiV2RateLimit = createRateLimit({ action: 'api.v2', - max: 100, + max: 1000, window: '1m', }); From 26f0c4c5b7a1f9106415cd43a2591e5e6f320fd4 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 23 Jul 2026 12:57:09 +0900 Subject: [PATCH 06/27] chore: deprecate endpoints (#3022) --- ARCHITECTURE.md | 2 +- .../content/docs/developers/api/documents.mdx | 2 + .../content/docs/developers/api/index.mdx | 2 + .../content/docs/developers/api/meta.json | 1 + .../developers/api/migrate-to-envelopes.mdx | 249 ++++++++++++++++++ .../content/docs/developers/api/templates.mdx | 2 + .../docs/developers/api/versioning.mdx | 11 + .../developers/examples/common-workflows.mdx | 2 + .../docs/developers/examples/index.mdx | 2 + .../getting-started/authentication.mdx | 2 + .../getting-started/first-api-call.mdx | 2 + .../docs/developers/getting-started/index.mdx | 2 + apps/docs/content/docs/developers/index.mdx | 2 + apps/docs/package.json | 2 +- .../src/components/mdx/envelope-warning.tsx | 19 ++ apps/docs/src/mdx-components.tsx | 2 + packages/api/v1/openapi.ts | 2 +- .../attachment/create-attachment.ts | 4 +- .../attachment/delete-attachment.ts | 4 +- .../attachment/find-attachments.ts | 4 +- .../attachment/update-attachment.ts | 4 +- .../create-document-temporary.types.ts | 2 +- .../document-router/create-document.types.ts | 4 +- .../document-router/delete-document.types.ts | 3 + .../distribute-document.types.ts | 4 +- .../download-document-beta.types.ts | 4 +- .../download-document.types.ts | 3 + .../duplicate-document.types.ts | 3 + .../document-router/find-documents.types.ts | 4 +- .../document-router/get-document.types.ts | 4 +- .../get-documents-by-ids.types.ts | 4 +- .../redistribute-document.types.ts | 3 +- .../document-router/update-document.types.ts | 3 + packages/trpc/server/field-router/router.ts | 44 +++- .../trpc/server/recipient-router/router.ts | 44 +++- .../get-templates-by-ids.types.ts | 4 +- .../trpc/server/template-router/router.ts | 39 ++- 37 files changed, 451 insertions(+), 43 deletions(-) create mode 100644 apps/docs/content/docs/developers/api/migrate-to-envelopes.mdx create mode 100644 apps/docs/src/components/mdx/envelope-warning.tsx diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index be9dbb555..d3cee2f37 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -42,8 +42,8 @@ Documenso is an open-source document signing platform built as a **monorepo** us | Package | Description | Port | | -------------------------- | -------------------------------------------------------- | ---- | | `@documenso/remix` | Main application - React Router (Remix) with Hono server | 3000 | -| `@documenso/documentation` | Documentation site (Next.js + Nextra) | 3002 | | `@documenso/openpage-api` | Public analytics API | 3003 | +| `@documenso/docs` | Documentation site | 3004 | ### Core Packages (`packages/`) diff --git a/apps/docs/content/docs/developers/api/documents.mdx b/apps/docs/content/docs/developers/api/documents.mdx index a21a2740b..dbe2e6a85 100644 --- a/apps/docs/content/docs/developers/api/documents.mdx +++ b/apps/docs/content/docs/developers/api/documents.mdx @@ -6,6 +6,8 @@ description: Create, manage, and send documents for signing via the API. import { Callout } from 'fumadocs-ui/components/callout'; import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; + + This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference, see the [OpenAPI Reference](https://openapi.documenso.com). diff --git a/apps/docs/content/docs/developers/api/index.mdx b/apps/docs/content/docs/developers/api/index.mdx index 7f446c7ad..e8d7139eb 100644 --- a/apps/docs/content/docs/developers/api/index.mdx +++ b/apps/docs/content/docs/developers/api/index.mdx @@ -5,6 +5,8 @@ description: Complete reference for the Documenso REST API. import { Callout } from 'fumadocs-ui/components/callout'; + + The guides below cover common API patterns but may not reflect the latest endpoints or parameters. For an always up-to-date reference, see the [OpenAPI Reference](https://openapi.documenso.com). diff --git a/apps/docs/content/docs/developers/api/meta.json b/apps/docs/content/docs/developers/api/meta.json index 7a19089dd..7906bbe97 100644 --- a/apps/docs/content/docs/developers/api/meta.json +++ b/apps/docs/content/docs/developers/api/meta.json @@ -8,6 +8,7 @@ "teams", "rate-limits", "versioning", + "migrate-to-envelopes", "developer-mode", "common-errors" ] diff --git a/apps/docs/content/docs/developers/api/migrate-to-envelopes.mdx b/apps/docs/content/docs/developers/api/migrate-to-envelopes.mdx new file mode 100644 index 000000000..2bd5c8568 --- /dev/null +++ b/apps/docs/content/docs/developers/api/migrate-to-envelopes.mdx @@ -0,0 +1,249 @@ +--- +title: Migrating to Envelopes +description: Why Documenso unified documents and templates into envelopes, and how to migrate from the deprecated document and template create endpoints. +--- + +import { Accordion, Accordions } from 'fumadocs-ui/components/accordion'; +import { Callout } from 'fumadocs-ui/components/callout'; +import { Step, Steps } from 'fumadocs-ui/components/steps'; +import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; + +## Summary + +The following items have been deprecated and will be removed on the 1st of March 2027: + +- API V1 +- A subset of SDK/API V2 endpoints +- Legacy documents and templates +- EmbedCreateDocumentV1 +- EmbedCreateTemplateV1 +- EmbedUpdateDocumentV1 +- EmbedUpdateTemplateV1 + +The beta endpoint `/api/v2-beta` will also be removed. Use `/api/v2` instead, which is a drop-in replacement. + +Nothing breaks before 1st of March 2027, so you can migrate at your own pace. + +## What are legacy documents and templates + +These are documents and templates created by the following endpoints: + +- `POST /api/v2/document/create` +- `POST /api/v2/document/create/beta` +- `POST /api/v2/template/create` +- `POST /api/v2/template/create/beta` +- `POST /api/v1/documents` +- `POST /api/v1/templates` +- `POST /api/v1/templates/create-document` +- `POST /api/v1/templates/generate-document` + +## What replaces legacy documents and templates + +At the end of 2025 we introduced a unified system for documents and templates, called envelopes. + +We still reference documents and templates throughout the documentation and application to distinguish them, but internally they are envelopes. + +Moving to the envelope system gives you: + +- **Multiple PDFs in one envelope.** Send several documents to sign in a single request. +- **One API for documents and templates.** Learn one set of endpoints instead of two misaligned ones. +- **A better editor and signing experience** for you and your recipients. + +## How to migrate + +{/* prettier-ignore */} + + + ### Switch to the envelope endpoints + + Replace each deprecated endpoint with its `/api/v2/envelope/*` equivalent from the [mapping tables](#endpoint-mapping-reference) below. + + + ### Set the envelope `type` on create + + A single endpoint, `POST /api/v2/envelope/create`, can create both documents and templates. Set `type` to `DOCUMENT` or `TEMPLATE`. You can now upload more than one PDF using the `files` field. + + + ### Update how you store IDs + + Envelope IDs are **strings** (for example `envelope_abc123`), not numbers. Update any code that stores, parses, or compares IDs. + + + ### Test, then remove the old calls + + Verify the new flow against your account, then delete the deprecated calls. + + + +The main data differences are as follows: +- ID format changed from number to string (e.g. `42` to `envelope_abc123`) +- pageNumber becomes page +- pageX becomes positionX +- pageY becomes positionY + +See the [Documents API](/docs/developers/api/documents) and [Templates API](/docs/developers/api/templates) for the full envelope reference. + +### Deprecated V1 API Endpoints + +Full reference in the [V1 OpenAPI reference](https://openapi-v1.documenso.com). + +| Deprecated endpoint | Replacement | +| -------------------------------------------------------- | ----------------------------------------------------- | +| `GET /api/v1/documents` | `GET /api/v2/envelope` | +| `GET /api/v1/documents/{id}` | `GET /api/v2/envelope/{envelopeId}` | +| `POST /api/v1/documents` | `POST /api/v2/envelope/create` | +| `POST /api/v1/documents/{id}/send` | `POST /api/v2/envelope/distribute` | +| `POST /api/v1/documents/{id}/resend` | `POST /api/v2/envelope/redistribute` | +| `DELETE /api/v1/documents/{id}` | `POST /api/v2/envelope/delete` | +| `GET /api/v1/documents/{id}/download` | `GET /api/v2/envelope/item/{envelopeItemId}/download` | +| `POST /api/v1/documents/{id}/recipients` | `POST /api/v2/envelope/recipient/create-many` | +| `PATCH /api/v1/documents/{id}/recipients/{recipientId}` | `POST /api/v2/envelope/recipient/update-many` | +| `DELETE /api/v1/documents/{id}/recipients/{recipientId}` | `POST /api/v2/envelope/recipient/delete` | +| `POST /api/v1/documents/{id}/fields` | `POST /api/v2/envelope/field/create-many` | +| `PATCH /api/v1/documents/{id}/fields/{fieldId}` | `POST /api/v2/envelope/field/update-many` | +| `DELETE /api/v1/documents/{id}/fields/{fieldId}` | `POST /api/v2/envelope/field/delete` | +| `GET /api/v1/templates` | `GET /api/v2/envelope` (with `type=TEMPLATE`) | +| `GET /api/v1/templates/{id}` | `GET /api/v2/envelope/{envelopeId}` | +| `POST /api/v1/templates` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) | +| `DELETE /api/v1/templates/{id}` | `POST /api/v2/envelope/delete` | +| `POST /api/v1/templates/{templateId}/create-document` | `POST /api/v2/envelope/use` | +| `POST /api/v1/templates/{templateId}/generate-document` | `POST /api/v2/envelope/use` | + +### Deprecated V2 API Endpoints + +Full reference in the [V2 OpenAPI reference](https://openapi.documenso.com). + +#### Documents + +| Deprecated endpoint | Replacement | +| ------------------------------------------------- | ----------------------------------------------------- | +| `GET /api/v2/document` | `GET /api/v2/envelope` | +| `GET /api/v2/document/{documentId}` | `GET /api/v2/envelope/{envelopeId}` | +| `POST /api/v2/document/get-many` | `POST /api/v2/envelope/get-many` | +| `POST /api/v2/document/create` | `POST /api/v2/envelope/create` | +| `POST /api/v2/document/create/beta` | `POST /api/v2/envelope/create` | +| `POST /api/v2/document/update` | `POST /api/v2/envelope/update` | +| `POST /api/v2/document/delete` | `POST /api/v2/envelope/delete` | +| `POST /api/v2/document/duplicate` | `POST /api/v2/envelope/duplicate` | +| `POST /api/v2/document/distribute` | `POST /api/v2/envelope/distribute` | +| `POST /api/v2/document/redistribute` | `POST /api/v2/envelope/redistribute` | +| `GET /api/v2/document/attachment` | `GET /api/v2/envelope/attachment` | +| `POST /api/v2/document/attachment/create` | `POST /api/v2/envelope/attachment/create` | +| `POST /api/v2/document/attachment/update` | `POST /api/v2/envelope/attachment/update` | +| `POST /api/v2/document/attachment/delete` | `POST /api/v2/envelope/attachment/delete` | +| `GET /api/v2/document/{documentId}/download` | `GET /api/v2/envelope/item/{envelopeItemId}/download` | +| `GET /api/v2/document/{documentId}/download-beta` | `GET /api/v2/envelope/item/{envelopeItemId}/download` | + +#### Templates + +| Deprecated endpoint | Replacement | +| ------------------------------------- | ------------------------------------------------ | +| `GET /api/v2/template` | `GET /api/v2/envelope` (with `type=TEMPLATE`) | +| `GET /api/v2/template/{templateId}` | `GET /api/v2/envelope/{envelopeId}` | +| `POST /api/v2/template/get-many` | `POST /api/v2/envelope/get-many` | +| `POST /api/v2/template/create` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) | +| `POST /api/v2/template/create/beta` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) | +| `POST /api/v2/template/update` | `POST /api/v2/envelope/update` | +| `POST /api/v2/template/duplicate` | `POST /api/v2/envelope/duplicate` | +| `POST /api/v2/template/delete` | `POST /api/v2/envelope/delete` | +| `POST /api/v2/template/use` | `POST /api/v2/envelope/use` | +| `POST /api/v2/template/direct/create` | **Pending replacement** | +| `POST /api/v2/template/direct/delete` | **Pending replacement** | +| `POST /api/v2/template/direct/toggle` | **Pending replacement** | + +#### Document fields + +| Deprecated endpoint | Replacement | +| ----------------------------------------- | ----------------------------------------- | +| `GET /api/v2/document/field/{fieldId}` | `GET /api/v2/envelope/field/{fieldId}` | +| `POST /api/v2/document/field/create` | `POST /api/v2/envelope/field/create-many` | +| `POST /api/v2/document/field/create-many` | `POST /api/v2/envelope/field/create-many` | +| `POST /api/v2/document/field/update` | `POST /api/v2/envelope/field/update-many` | +| `POST /api/v2/document/field/update-many` | `POST /api/v2/envelope/field/update-many` | +| `POST /api/v2/document/field/delete` | `POST /api/v2/envelope/field/delete` | + +#### Template fields + +| Deprecated endpoint | Replacement | +| ----------------------------------------- | ----------------------------------------- | +| `GET /api/v2/template/field/{fieldId}` | `GET /api/v2/envelope/field/{fieldId}` | +| `POST /api/v2/template/field/create` | `POST /api/v2/envelope/field/create-many` | +| `POST /api/v2/template/field/create-many` | `POST /api/v2/envelope/field/create-many` | +| `POST /api/v2/template/field/update` | `POST /api/v2/envelope/field/update-many` | +| `POST /api/v2/template/field/update-many` | `POST /api/v2/envelope/field/update-many` | +| `POST /api/v2/template/field/delete` | `POST /api/v2/envelope/field/delete` | + +#### Document recipients + +| Deprecated endpoint | Replacement | +| ---------------------------------------------- | ---------------------------------------------- | +| `GET /api/v2/document/recipient/{recipientId}` | `GET /api/v2/envelope/recipient/{recipientId}` | +| `POST /api/v2/document/recipient/create` | `POST /api/v2/envelope/recipient/create-many` | +| `POST /api/v2/document/recipient/create-many` | `POST /api/v2/envelope/recipient/create-many` | +| `POST /api/v2/document/recipient/update` | `POST /api/v2/envelope/recipient/update-many` | +| `POST /api/v2/document/recipient/update-many` | `POST /api/v2/envelope/recipient/update-many` | +| `POST /api/v2/document/recipient/delete` | `POST /api/v2/envelope/recipient/delete` | + +#### Template recipients + +| Deprecated endpoint | Replacement | +| ---------------------------------------------- | ---------------------------------------------- | +| `GET /api/v2/template/recipient/{recipientId}` | `GET /api/v2/envelope/recipient/{recipientId}` | +| `POST /api/v2/template/recipient/create` | `POST /api/v2/envelope/recipient/create-many` | +| `POST /api/v2/template/recipient/create-many` | `POST /api/v2/envelope/recipient/create-many` | +| `POST /api/v2/template/recipient/update` | `POST /api/v2/envelope/recipient/update-many` | +| `POST /api/v2/template/recipient/update-many` | `POST /api/v2/envelope/recipient/update-many` | +| `POST /api/v2/template/recipient/delete` | `POST /api/v2/envelope/recipient/delete` | + +### Embedding components + +| Deprecated component | Replacement | +| ----------------------- | --------------------- | +| `EmbedCreateDocumentV1` | `EmbedCreateEnvelope` | +| `EmbedCreateTemplateV1` | `EmbedCreateEnvelope` | +| `EmbedUpdateDocumentV1` | `EmbedUpdateEnvelope` | +| `EmbedUpdateTemplateV1` | `EmbedUpdateEnvelope` | + +See the [embedding guide](/docs/developers/embedding) for the envelope components. + +## FAQ + + + + The deprecated V1 API, the V2 endpoints listed above, and the V1 embedding components are removed. + Requests to them will fail, so migrate to the envelope API before that date. + + + Yes. Documents and templates you already created remain in your account and continue to work. They will automatically be converted to envelopes. Only + the deprecated endpoints you call are going away. Your data is not deleted. + + + No. Authentication is unchanged. The same API token works for the envelope endpoints under + `https://app.documenso.com/api/v2`. + + + Both are envelopes, distinguished by a `type` field of `DOCUMENT` or `TEMPLATE`. They share the same + endpoints, recipients, fields, and attachments. + + + The function calls to the legacy endpoints will break on the 1st of March 2027. Update to the latest SDK version and switch to its envelope methods. + The deprecated document and template methods map to the envelope endpoints in the tables above. + + + Reach out to [support@documenso.com](mailto:support@documenso.com) with your use case and we will + help you plan the migration. + + + +## Getting help + +- [V2 OpenAPI reference](https://openapi.documenso.com): the up-to-date envelope API. +- [V1 OpenAPI reference](https://openapi-v1.documenso.com): the deprecated V1 API. +- [support@documenso.com](mailto:support@documenso.com): migration questions and extensions. + +## See also + +- [Documents API](/docs/developers/api/documents): create and manage envelopes +- [Templates API](/docs/developers/api/templates): work with templates and direct links +- [Fields API](/docs/developers/api/fields) and [Recipients API](/docs/developers/api/recipients) +- [API Versioning](/docs/developers/api/versioning): how Documenso versions the public API diff --git a/apps/docs/content/docs/developers/api/templates.mdx b/apps/docs/content/docs/developers/api/templates.mdx index 8f5c5b667..b3f52e146 100644 --- a/apps/docs/content/docs/developers/api/templates.mdx +++ b/apps/docs/content/docs/developers/api/templates.mdx @@ -6,6 +6,8 @@ description: Create documents from reusable templates via API. import { Callout } from 'fumadocs-ui/components/callout'; import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; + + This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference, see the [OpenAPI Reference](https://openapi.documenso.com). diff --git a/apps/docs/content/docs/developers/api/versioning.mdx b/apps/docs/content/docs/developers/api/versioning.mdx index c137a869a..9e9435034 100644 --- a/apps/docs/content/docs/developers/api/versioning.mdx +++ b/apps/docs/content/docs/developers/api/versioning.mdx @@ -5,6 +5,8 @@ description: Versioning information for the Documenso public API. import { Callout } from 'fumadocs-ui/components/callout'; + + ## Overview Documenso uses API versioning to manage changes to the public API. This allows us to introduce new features, fix bugs, and make other changes without breaking existing integrations. @@ -19,7 +21,16 @@ Also, we may deprecate certain features or endpoints in the API. When we depreca --- +## Documents, Templates, and Envelopes + +Documenso has unified documents and templates into a single resource called an **envelope**. New integrations should create documents and templates through the `/envelope/*` endpoints. The `POST /document/create` and `POST /template/create` endpoints (including their `/beta` variants) are deprecated in favor of `POST /envelope/create`. + +See [Migrating to the Envelope API](/docs/developers/api/migrate-to-envelopes) for the rationale and step-by-step migration examples. + +--- + ## See Also +- [Migrating to the Envelope API](/docs/developers/api/migrate-to-envelopes) - Move from the document and template create endpoints - [Authentication](/docs/developers/getting-started/authentication) - API authentication guide - [Rate Limits](/docs/developers/api/rate-limits) - API rate limit details diff --git a/apps/docs/content/docs/developers/examples/common-workflows.mdx b/apps/docs/content/docs/developers/examples/common-workflows.mdx index e0447d7c4..704bf415f 100644 --- a/apps/docs/content/docs/developers/examples/common-workflows.mdx +++ b/apps/docs/content/docs/developers/examples/common-workflows.mdx @@ -8,6 +8,8 @@ import { Callout } from 'fumadocs-ui/components/callout'; import { Step, Steps } from 'fumadocs-ui/components/steps'; import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; + + ## Workflow 1: Send a Document for Signature The most common workflow: upload a PDF, add recipients with signature fields, and send for signing. diff --git a/apps/docs/content/docs/developers/examples/index.mdx b/apps/docs/content/docs/developers/examples/index.mdx index bdbcdb0b5..aab7191cc 100644 --- a/apps/docs/content/docs/developers/examples/index.mdx +++ b/apps/docs/content/docs/developers/examples/index.mdx @@ -3,6 +3,8 @@ title: Examples description: Common integration patterns and end-to-end workflows. --- + + + ## Prerequisites - A Documenso account (cloud or self-hosted) diff --git a/apps/docs/content/docs/developers/getting-started/first-api-call.mdx b/apps/docs/content/docs/developers/getting-started/first-api-call.mdx index 665a90afd..e87b85438 100644 --- a/apps/docs/content/docs/developers/getting-started/first-api-call.mdx +++ b/apps/docs/content/docs/developers/getting-started/first-api-call.mdx @@ -7,6 +7,8 @@ import { Callout } from 'fumadocs-ui/components/callout'; import { Step, Steps } from 'fumadocs-ui/components/steps'; import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; + + ## Prerequisites Before starting, you need: diff --git a/apps/docs/content/docs/developers/getting-started/index.mdx b/apps/docs/content/docs/developers/getting-started/index.mdx index d2070f2b5..f38145d7e 100644 --- a/apps/docs/content/docs/developers/getting-started/index.mdx +++ b/apps/docs/content/docs/developers/getting-started/index.mdx @@ -3,6 +3,8 @@ title: Getting Started description: Get your API key and make your first API call. --- + + + ## Getting Started diff --git a/apps/docs/package.json b/apps/docs/package.json index da9966679..9f345d14c 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "scripts": { - "build": "NEXT_IGNORE_INCORRECT_LOCKFILE=true next build", + "build": "next build", "dev": "next dev", "start": "next start", "types:check": "fumadocs-mdx && next typegen && tsc --noEmit", diff --git a/apps/docs/src/components/mdx/envelope-warning.tsx b/apps/docs/src/components/mdx/envelope-warning.tsx new file mode 100644 index 000000000..18676a78d --- /dev/null +++ b/apps/docs/src/components/mdx/envelope-warning.tsx @@ -0,0 +1,19 @@ +import { Callout } from 'fumadocs-ui/components/callout'; + +const MIGRATION_GUIDE_HREF = '/docs/developers/api/migrate-to-envelopes'; + +/** + * Deprecation banner steering API consumers away from the legacy document and + * template create endpoints and towards the unified Envelope API. + * + * Registered globally in `mdx-components.tsx`, so it can be used in any MDX page + * as `` without an explicit import. + */ +export function EnvelopeWarning() { + return ( + + Documents and templates are being deprecated and replaced by envelopes.{' '} + Read the migration guide here. + + ); +} diff --git a/apps/docs/src/mdx-components.tsx b/apps/docs/src/mdx-components.tsx index 298b70960..a0116880a 100644 --- a/apps/docs/src/mdx-components.tsx +++ b/apps/docs/src/mdx-components.tsx @@ -1,6 +1,7 @@ import * as TabsComponents from 'fumadocs-ui/components/tabs'; import defaultMdxComponents from 'fumadocs-ui/mdx'; import type { MDXComponents } from 'mdx/types'; +import { EnvelopeWarning } from '@/components/mdx/envelope-warning'; import { Mermaid } from '@/components/mdx/mermaid'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -9,6 +10,7 @@ export function getMDXComponents(components?: MDXComponents): any { ...defaultMdxComponents, ...TabsComponents, Mermaid, + EnvelopeWarning, ...components, }; } diff --git a/packages/api/v1/openapi.ts b/packages/api/v1/openapi.ts index d3ee2a4ee..9a64b9b5c 100644 --- a/packages/api/v1/openapi.ts +++ b/packages/api/v1/openapi.ts @@ -11,7 +11,7 @@ export const OpenAPIV1 = Object.assign( title: 'Documenso API', version: '1.0.0', description: - 'API V1 is deprecated, but will continue to be supported. For more details, see https://docs.documenso.com/developers/public-api. \n\nThe Documenso API for retrieving, creating, updating and deleting documents.', + 'API V1 has been deprecated. For more details, see https://docs.documenso.com/docs/developers/api/migrate-to-envelopes. \n\nThe Documenso API for retrieving, creating, updating and deleting documents.', }, servers: [ { diff --git a/packages/trpc/server/document-router/attachment/create-attachment.ts b/packages/trpc/server/document-router/attachment/create-attachment.ts index 9b754bc62..844f16cb8 100644 --- a/packages/trpc/server/document-router/attachment/create-attachment.ts +++ b/packages/trpc/server/document-router/attachment/create-attachment.ts @@ -12,8 +12,10 @@ export const createAttachmentRoute = authenticatedProcedure method: 'POST', path: '/document/attachment/create', summary: 'Create attachment', - description: 'Create a new attachment for a document', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Create a new attachment for a document', tags: ['Document'], + deprecated: true, }, }) .input(ZCreateAttachmentRequestSchema) diff --git a/packages/trpc/server/document-router/attachment/delete-attachment.ts b/packages/trpc/server/document-router/attachment/delete-attachment.ts index f26ef8c3b..f965bb2e9 100644 --- a/packages/trpc/server/document-router/attachment/delete-attachment.ts +++ b/packages/trpc/server/document-router/attachment/delete-attachment.ts @@ -10,8 +10,10 @@ export const deleteAttachmentRoute = authenticatedProcedure method: 'POST', path: '/document/attachment/delete', summary: 'Delete attachment', - description: 'Delete an attachment from a document', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Delete an attachment from a document', tags: ['Document'], + deprecated: true, }, }) .input(ZDeleteAttachmentRequestSchema) diff --git a/packages/trpc/server/document-router/attachment/find-attachments.ts b/packages/trpc/server/document-router/attachment/find-attachments.ts index a251b12bf..de348440c 100644 --- a/packages/trpc/server/document-router/attachment/find-attachments.ts +++ b/packages/trpc/server/document-router/attachment/find-attachments.ts @@ -12,8 +12,10 @@ export const findAttachmentsRoute = authenticatedProcedure method: 'GET', path: '/document/attachment', summary: 'Find attachments', - description: 'Find all attachments for a document', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Find all attachments for a document', tags: ['Document'], + deprecated: true, }, }) .input(ZFindAttachmentsRequestSchema) diff --git a/packages/trpc/server/document-router/attachment/update-attachment.ts b/packages/trpc/server/document-router/attachment/update-attachment.ts index 789fc6605..d5f5cf244 100644 --- a/packages/trpc/server/document-router/attachment/update-attachment.ts +++ b/packages/trpc/server/document-router/attachment/update-attachment.ts @@ -10,8 +10,10 @@ export const updateAttachmentRoute = authenticatedProcedure method: 'POST', path: '/document/attachment/update', summary: 'Update attachment', - description: 'Update an existing attachment', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Update an existing attachment', tags: ['Document'], + deprecated: true, }, }) .input(ZUpdateAttachmentRequestSchema) diff --git a/packages/trpc/server/document-router/create-document-temporary.types.ts b/packages/trpc/server/document-router/create-document-temporary.types.ts index bdcb2202c..039d43255 100644 --- a/packages/trpc/server/document-router/create-document-temporary.types.ts +++ b/packages/trpc/server/document-router/create-document-temporary.types.ts @@ -27,7 +27,7 @@ export const createDocumentTemporaryMeta: TrpcRouteMeta = { path: '/document/create/beta', summary: 'Create document', description: - 'You will need to upload the PDF to the provided URL returned. Note: Once V2 API is released, this will be removed since we will allow direct uploads, instead of using an upload URL.', + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. You will need to upload the PDF to the provided URL returned. This endpoint will be removed since we will allow direct uploads, instead of using an upload URL.', tags: ['Document'], deprecated: true, }, diff --git a/packages/trpc/server/document-router/create-document.types.ts b/packages/trpc/server/document-router/create-document.types.ts index 383fb3780..5bbabd224 100644 --- a/packages/trpc/server/document-router/create-document.types.ts +++ b/packages/trpc/server/document-router/create-document.types.ts @@ -25,8 +25,10 @@ export const createDocumentMeta: TrpcRouteMeta = { path: '/document/create', contentTypes: ['multipart/form-data'], summary: 'Create document', - description: 'Create a document using form data.', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide.', tags: ['Document'], + deprecated: true, }, }; diff --git a/packages/trpc/server/document-router/delete-document.types.ts b/packages/trpc/server/document-router/delete-document.types.ts index a84ac0027..93e9567cd 100644 --- a/packages/trpc/server/document-router/delete-document.types.ts +++ b/packages/trpc/server/document-router/delete-document.types.ts @@ -8,7 +8,10 @@ export const deleteDocumentMeta: TrpcRouteMeta = { method: 'POST', path: '/document/delete', summary: 'Delete document', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide.', tags: ['Document'], + deprecated: true, }, }; diff --git a/packages/trpc/server/document-router/distribute-document.types.ts b/packages/trpc/server/document-router/distribute-document.types.ts index e44554f20..2c9de4d08 100644 --- a/packages/trpc/server/document-router/distribute-document.types.ts +++ b/packages/trpc/server/document-router/distribute-document.types.ts @@ -19,8 +19,10 @@ export const distributeDocumentMeta: TrpcRouteMeta = { method: 'POST', path: '/document/distribute', summary: 'Distribute document', - description: 'Send the document out to recipients based on your distribution method', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Send the document out to recipients based on your distribution method', tags: ['Document'], + deprecated: true, }, }; diff --git a/packages/trpc/server/document-router/download-document-beta.types.ts b/packages/trpc/server/document-router/download-document-beta.types.ts index be4f454f8..db49dd539 100644 --- a/packages/trpc/server/document-router/download-document-beta.types.ts +++ b/packages/trpc/server/document-router/download-document-beta.types.ts @@ -7,8 +7,10 @@ export const downloadDocumentMeta: TrpcRouteMeta = { method: 'GET', path: '/document/{documentId}/download-beta', summary: 'Download document (beta)', - description: 'Get a pre-signed download URL for the original or signed version of a document', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Get a pre-signed download URL for the original or signed version of a document', tags: ['Document'], + deprecated: true, }, }; diff --git a/packages/trpc/server/document-router/download-document.types.ts b/packages/trpc/server/document-router/download-document.types.ts index 9d4003443..10490364c 100644 --- a/packages/trpc/server/document-router/download-document.types.ts +++ b/packages/trpc/server/document-router/download-document.types.ts @@ -7,7 +7,10 @@ export const downloadDocumentMeta: TrpcRouteMeta = { method: 'GET', path: '/document/{documentId}/download', summary: 'Download document', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide.', tags: ['Document'], + deprecated: true, responseHeaders: z.object({ 'Content-Type': z.literal('application/pdf'), }), diff --git a/packages/trpc/server/document-router/duplicate-document.types.ts b/packages/trpc/server/document-router/duplicate-document.types.ts index 7f33b44c4..c7b104210 100644 --- a/packages/trpc/server/document-router/duplicate-document.types.ts +++ b/packages/trpc/server/document-router/duplicate-document.types.ts @@ -7,7 +7,10 @@ export const duplicateDocumentMeta: TrpcRouteMeta = { method: 'POST', path: '/document/duplicate', summary: 'Duplicate document', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide.', tags: ['Document'], + deprecated: true, }, }; diff --git a/packages/trpc/server/document-router/find-documents.types.ts b/packages/trpc/server/document-router/find-documents.types.ts index 81ee4e3bf..b41b9456c 100644 --- a/packages/trpc/server/document-router/find-documents.types.ts +++ b/packages/trpc/server/document-router/find-documents.types.ts @@ -10,8 +10,10 @@ export const ZFindDocumentsMeta: TrpcRouteMeta = { method: 'GET', path: '/document', summary: 'Find documents', - description: 'Find documents based on a search criteria', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Find documents based on a search criteria', tags: ['Document'], + deprecated: true, }, }; diff --git a/packages/trpc/server/document-router/get-document.types.ts b/packages/trpc/server/document-router/get-document.types.ts index dd8a8dc6b..f25217051 100644 --- a/packages/trpc/server/document-router/get-document.types.ts +++ b/packages/trpc/server/document-router/get-document.types.ts @@ -8,8 +8,10 @@ export const getDocumentMeta: TrpcRouteMeta = { method: 'GET', path: '/document/{documentId}', summary: 'Get document', - description: 'Returns a document given an ID', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Returns a document given an ID', tags: ['Document'], + deprecated: true, }, }; diff --git a/packages/trpc/server/document-router/get-documents-by-ids.types.ts b/packages/trpc/server/document-router/get-documents-by-ids.types.ts index d1871593a..7181ffb2a 100644 --- a/packages/trpc/server/document-router/get-documents-by-ids.types.ts +++ b/packages/trpc/server/document-router/get-documents-by-ids.types.ts @@ -8,8 +8,10 @@ export const getDocumentsByIdsMeta: TrpcRouteMeta = { method: 'POST', path: '/document/get-many', summary: 'Get multiple documents', - description: 'Retrieve multiple documents by their IDs', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Retrieve multiple documents by their IDs', tags: ['Document'], + deprecated: true, }, }; diff --git a/packages/trpc/server/document-router/redistribute-document.types.ts b/packages/trpc/server/document-router/redistribute-document.types.ts index d81b4204e..7fde6c373 100644 --- a/packages/trpc/server/document-router/redistribute-document.types.ts +++ b/packages/trpc/server/document-router/redistribute-document.types.ts @@ -9,8 +9,9 @@ export const redistributeDocumentMeta: TrpcRouteMeta = { path: '/document/redistribute', summary: 'Redistribute document', description: - 'Redistribute the document to the provided recipients who have not actioned the document. Will use the distribution method set in the document', + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Redistribute the document to the provided recipients who have not actioned the document. Will use the distribution method set in the document', tags: ['Document'], + deprecated: true, }, }; diff --git a/packages/trpc/server/document-router/update-document.types.ts b/packages/trpc/server/document-router/update-document.types.ts index 01700f19d..ac70c9c21 100644 --- a/packages/trpc/server/document-router/update-document.types.ts +++ b/packages/trpc/server/document-router/update-document.types.ts @@ -13,7 +13,10 @@ export const updateDocumentMeta: TrpcRouteMeta = { method: 'POST', path: '/document/update', summary: 'Update document', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide.', tags: ['Document'], + deprecated: true, }, }; diff --git a/packages/trpc/server/field-router/router.ts b/packages/trpc/server/field-router/router.ts index 6b2833ee5..39d207c5c 100644 --- a/packages/trpc/server/field-router/router.ts +++ b/packages/trpc/server/field-router/router.ts @@ -51,8 +51,9 @@ export const fieldRouter = router({ path: '/document/field/{fieldId}', summary: 'Get document field', description: - 'Returns a single field. If you want to retrieve all the fields for a document, use the "Get Document" endpoint.', + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Returns a single field. If you want to retrieve all the fields for a document, use the "Get Document" endpoint.', tags: ['Document Fields'], + deprecated: true, }, }) .input(ZGetFieldRequestSchema) @@ -84,8 +85,10 @@ export const fieldRouter = router({ method: 'POST', path: '/document/field/create', summary: 'Create document field', - description: 'Create a single field for a document.', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Create a single field for a document.', tags: ['Document Fields'], + deprecated: true, }, }) .input(ZCreateDocumentFieldRequestSchema) @@ -130,8 +133,10 @@ export const fieldRouter = router({ method: 'POST', path: '/document/field/create-many', summary: 'Create document fields', - description: 'Create multiple fields for a document.', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Create multiple fields for a document.', tags: ['Document Fields'], + deprecated: true, }, }) .input(ZCreateDocumentFieldsRequestSchema) @@ -172,8 +177,10 @@ export const fieldRouter = router({ method: 'POST', path: '/document/field/update', summary: 'Update document field', - description: 'Update a single field for a document.', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Update a single field for a document.', tags: ['Document Fields'], + deprecated: true, }, }) .input(ZUpdateDocumentFieldRequestSchema) @@ -212,8 +219,10 @@ export const fieldRouter = router({ method: 'POST', path: '/document/field/update-many', summary: 'Update document fields', - description: 'Update multiple fields for a document.', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Update multiple fields for a document.', tags: ['Document Fields'], + deprecated: true, }, }) .input(ZUpdateDocumentFieldsRequestSchema) @@ -250,7 +259,10 @@ export const fieldRouter = router({ method: 'POST', path: '/document/field/delete', summary: 'Delete document field', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide.', tags: ['Document Fields'], + deprecated: true, }, }) .input(ZDeleteDocumentFieldRequestSchema) @@ -323,8 +335,10 @@ export const fieldRouter = router({ method: 'POST', path: '/template/field/create', summary: 'Create template field', - description: 'Create a single field for a template.', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Create a single field for a template.', tags: ['Template Fields'], + deprecated: true, }, }) .input(ZCreateTemplateFieldRequestSchema) @@ -370,8 +384,9 @@ export const fieldRouter = router({ path: '/template/field/{fieldId}', summary: 'Get template field', description: - 'Returns a single field. If you want to retrieve all the fields for a template, use the "Get Template" endpoint.', + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Returns a single field. If you want to retrieve all the fields for a template, use the "Get Template" endpoint.', tags: ['Template Fields'], + deprecated: true, }, }) .input(ZGetFieldRequestSchema) @@ -403,8 +418,10 @@ export const fieldRouter = router({ method: 'POST', path: '/template/field/create-many', summary: 'Create template fields', - description: 'Create multiple fields for a template.', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Create multiple fields for a template.', tags: ['Template Fields'], + deprecated: true, }, }) .input(ZCreateTemplateFieldsRequestSchema) @@ -445,8 +462,10 @@ export const fieldRouter = router({ method: 'POST', path: '/template/field/update', summary: 'Update template field', - description: 'Update a single field for a template.', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Update a single field for a template.', tags: ['Template Fields'], + deprecated: true, }, }) .input(ZUpdateTemplateFieldRequestSchema) @@ -485,8 +504,10 @@ export const fieldRouter = router({ method: 'POST', path: '/template/field/update-many', summary: 'Update template fields', - description: 'Update multiple fields for a template.', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Update multiple fields for a template.', tags: ['Template Fields'], + deprecated: true, }, }) .input(ZUpdateTemplateFieldsRequestSchema) @@ -523,7 +544,10 @@ export const fieldRouter = router({ method: 'POST', path: '/template/field/delete', summary: 'Delete template field', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide.', tags: ['Template Fields'], + deprecated: true, }, }) .input(ZDeleteTemplateFieldRequestSchema) diff --git a/packages/trpc/server/recipient-router/router.ts b/packages/trpc/server/recipient-router/router.ts index 78dab10ab..72c4f7296 100644 --- a/packages/trpc/server/recipient-router/router.ts +++ b/packages/trpc/server/recipient-router/router.ts @@ -60,8 +60,9 @@ export const recipientRouter = router({ path: '/document/recipient/{recipientId}', summary: 'Get document recipient', description: - 'Returns a single recipient. If you want to retrieve all the recipients for a document, use the "Get Document" endpoint.', + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Returns a single recipient. If you want to retrieve all the recipients for a document, use the "Get Document" endpoint.', tags: ['Document Recipients'], + deprecated: true, }, }) .input(ZGetRecipientRequestSchema) @@ -93,8 +94,10 @@ export const recipientRouter = router({ method: 'POST', path: '/document/recipient/create', summary: 'Create document recipient', - description: 'Create a single recipient for a document.', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Create a single recipient for a document.', tags: ['Document Recipients'], + deprecated: true, }, }) .input(ZCreateDocumentRecipientRequestSchema) @@ -132,8 +135,10 @@ export const recipientRouter = router({ method: 'POST', path: '/document/recipient/create-many', summary: 'Create document recipients', - description: 'Create multiple recipients for a document.', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Create multiple recipients for a document.', tags: ['Document Recipients'], + deprecated: true, }, }) .input(ZCreateDocumentRecipientsRequestSchema) @@ -169,8 +174,10 @@ export const recipientRouter = router({ method: 'POST', path: '/document/recipient/update', summary: 'Update document recipient', - description: 'Update a single recipient for a document.', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Update a single recipient for a document.', tags: ['Document Recipients'], + deprecated: true, }, }) .input(ZUpdateDocumentRecipientRequestSchema) @@ -208,8 +215,10 @@ export const recipientRouter = router({ method: 'POST', path: '/document/recipient/update-many', summary: 'Update document recipients', - description: 'Update multiple recipients for a document.', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Update multiple recipients for a document.', tags: ['Document Recipients'], + deprecated: true, }, }) .input(ZUpdateDocumentRecipientsRequestSchema) @@ -245,7 +254,10 @@ export const recipientRouter = router({ method: 'POST', path: '/document/recipient/delete', summary: 'Delete document recipient', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide.', tags: ['Document Recipients'], + deprecated: true, }, }) .input(ZDeleteDocumentRecipientRequestSchema) @@ -315,8 +327,9 @@ export const recipientRouter = router({ path: '/template/recipient/{recipientId}', summary: 'Get template recipient', description: - 'Returns a single recipient. If you want to retrieve all the recipients for a template, use the "Get Template" endpoint.', + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Returns a single recipient. If you want to retrieve all the recipients for a template, use the "Get Template" endpoint.', tags: ['Template Recipients'], + deprecated: true, }, }) .input(ZGetRecipientRequestSchema) @@ -348,8 +361,10 @@ export const recipientRouter = router({ method: 'POST', path: '/template/recipient/create', summary: 'Create template recipient', - description: 'Create a single recipient for a template.', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Create a single recipient for a template.', tags: ['Template Recipients'], + deprecated: true, }, }) .input(ZCreateTemplateRecipientRequestSchema) @@ -387,8 +402,10 @@ export const recipientRouter = router({ method: 'POST', path: '/template/recipient/create-many', summary: 'Create template recipients', - description: 'Create multiple recipients for a template.', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Create multiple recipients for a template.', tags: ['Template Recipients'], + deprecated: true, }, }) .input(ZCreateTemplateRecipientsRequestSchema) @@ -424,8 +441,10 @@ export const recipientRouter = router({ method: 'POST', path: '/template/recipient/update', summary: 'Update template recipient', - description: 'Update a single recipient for a template.', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Update a single recipient for a template.', tags: ['Template Recipients'], + deprecated: true, }, }) .input(ZUpdateTemplateRecipientRequestSchema) @@ -463,8 +482,10 @@ export const recipientRouter = router({ method: 'POST', path: '/template/recipient/update-many', summary: 'Update template recipients', - description: 'Update multiple recipients for a template.', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Update multiple recipients for a template.', tags: ['Template Recipients'], + deprecated: true, }, }) .input(ZUpdateTemplateRecipientsRequestSchema) @@ -500,7 +521,10 @@ export const recipientRouter = router({ method: 'POST', path: '/template/recipient/delete', summary: 'Delete template recipient', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide.', tags: ['Template Recipients'], + deprecated: true, }, }) .input(ZDeleteTemplateRecipientRequestSchema) diff --git a/packages/trpc/server/template-router/get-templates-by-ids.types.ts b/packages/trpc/server/template-router/get-templates-by-ids.types.ts index 6048f0387..0210054ce 100644 --- a/packages/trpc/server/template-router/get-templates-by-ids.types.ts +++ b/packages/trpc/server/template-router/get-templates-by-ids.types.ts @@ -8,8 +8,10 @@ export const getTemplatesByIdsMeta: TrpcRouteMeta = { method: 'POST', path: '/template/get-many', summary: 'Get multiple templates', - description: 'Retrieve multiple templates by their IDs', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Retrieve multiple templates by their IDs', tags: ['Template'], + deprecated: true, }, }; diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts index 2ee6c2586..99d4de784 100644 --- a/packages/trpc/server/template-router/router.ts +++ b/packages/trpc/server/template-router/router.ts @@ -72,8 +72,10 @@ export const templateRouter = router({ method: 'GET', path: '/template', summary: 'Find templates', - description: 'Find templates based on a search criteria', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Find templates based on a search criteria', tags: ['Template'], + deprecated: true, }, }) .input(ZFindTemplatesRequestSchema) @@ -201,7 +203,10 @@ export const templateRouter = router({ method: 'GET', path: '/template/{templateId}', summary: 'Get template', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide.', tags: ['Template'], + deprecated: true, }, }) .input(ZGetTemplateByIdRequestSchema) @@ -245,8 +250,10 @@ export const templateRouter = router({ path: '/template/create', contentTypes: ['multipart/form-data'], summary: 'Create template', - description: 'Create a new template', + description: + 'Create a new template. Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide.', tags: ['Template'], + deprecated: true, }, }) .input(ZCreateTemplateMutationSchema) @@ -334,8 +341,9 @@ export const templateRouter = router({ path: '/template/create/beta', summary: 'Create template', description: - 'You will need to upload the PDF to the provided URL returned. Note: Once V2 API is released, this will be removed since we will allow direct uploads, instead of using an upload URL.', + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. You will need to upload the PDF to the provided URL returned. Note: Once V2 API is released, this will be removed since we will allow direct uploads, instead of using an upload URL.', tags: ['Template'], + deprecated: true, }, }) .input(ZCreateTemplateV2RequestSchema) @@ -418,7 +426,10 @@ export const templateRouter = router({ method: 'POST', path: '/template/update', summary: 'Update template', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide.', tags: ['Template'], + deprecated: true, }, }) .input(ZUpdateTemplateRequestSchema) @@ -461,7 +472,10 @@ export const templateRouter = router({ method: 'POST', path: '/template/duplicate', summary: 'Duplicate template', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide.', tags: ['Template'], + deprecated: true, }, }) .input(ZDuplicateTemplateMutationSchema) @@ -497,7 +511,10 @@ export const templateRouter = router({ method: 'POST', path: '/template/delete', summary: 'Delete template', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide.', tags: ['Template'], + deprecated: true, }, }) .input(ZDeleteTemplateMutationSchema) @@ -534,8 +551,10 @@ export const templateRouter = router({ method: 'POST', path: '/template/use', summary: 'Use template', - description: 'Use the template to create a document', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Use the template to create a document', tags: ['Template'], + deprecated: true, }, }) .input(ZCreateDocumentFromTemplateRequestSchema) @@ -687,8 +706,10 @@ export const templateRouter = router({ method: 'POST', path: '/template/direct/create', summary: 'Create direct link', - description: 'Create a direct link for a template', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Create a direct link for a template', tags: ['Template'], + deprecated: true, }, }) .input(ZCreateTemplateDirectLinkRequestSchema) @@ -743,8 +764,10 @@ export const templateRouter = router({ method: 'POST', path: '/template/direct/delete', summary: 'Delete direct link', - description: 'Delete a direct link for a template', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Delete a direct link for a template', tags: ['Template'], + deprecated: true, }, }) .input(ZDeleteTemplateDirectLinkRequestSchema) @@ -775,8 +798,10 @@ export const templateRouter = router({ method: 'POST', path: '/template/direct/toggle', summary: 'Toggle direct link', - description: 'Enable or disable a direct link for a template', + description: + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Enable or disable a direct link for a template', tags: ['Template'], + deprecated: true, }, }) .input(ZToggleTemplateDirectLinkRequestSchema) From 54befb5962c12fd33911d36d86386dae945de6ef Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 23 Jul 2026 13:09:06 +0900 Subject: [PATCH 07/27] fix: update stripe team member billing (#2991) --- .../organisation.decline.$token.tsx | 95 +---- .../organisation.invite.$token.tsx | 335 ++++++++++++++---- .../sync-stripe-customer-subscription.ts | 14 + .../update-subscription-item-quantity.ts | 137 +++++-- .../ee/server-only/stripe/webhook/handler.ts | 1 - packages/lib/jobs/client.ts | 4 + .../alert-organisation-seat-drift.handler.ts | 67 ++++ .../internal/alert-organisation-seat-drift.ts | 30 ++ .../sync-organisation-seats.handler.ts | 54 +++ .../internal/sync-organisation-seats.ts | 29 ++ .../accept-organisation-invitation.ts | 43 ++- .../create-organisation-member-invites.ts | 24 -- packages/lib/server-only/user/delete-user.ts | 22 +- .../delete-organisation-member.ts | 31 +- .../delete-organisation-member-invites.ts | 31 -- .../delete-organisation-members.ts | 31 +- .../organisation-router/leave-organisation.ts | 35 +- 17 files changed, 674 insertions(+), 309 deletions(-) create mode 100644 packages/lib/jobs/definitions/internal/alert-organisation-seat-drift.handler.ts create mode 100644 packages/lib/jobs/definitions/internal/alert-organisation-seat-drift.ts create mode 100644 packages/lib/jobs/definitions/internal/sync-organisation-seats.handler.ts create mode 100644 packages/lib/jobs/definitions/internal/sync-organisation-seats.ts diff --git a/apps/remix/app/routes/_unauthenticated+/organisation.decline.$token.tsx b/apps/remix/app/routes/_unauthenticated+/organisation.decline.$token.tsx index fc7c90fac..b95b3df2a 100644 --- a/apps/remix/app/routes/_unauthenticated+/organisation.decline.$token.tsx +++ b/apps/remix/app/routes/_unauthenticated+/organisation.decline.$token.tsx @@ -1,98 +1,15 @@ -import { prisma } from '@documenso/prisma'; -import { Button } from '@documenso/ui/primitives/button'; -import { Trans } from '@lingui/react/macro'; -import { OrganisationMemberInviteStatus } from '@prisma/client'; -import { Link } from 'react-router'; +import { redirect } from 'react-router'; import type { Route } from './+types/organisation.decline.$token'; -export async function loader({ params }: Route.LoaderArgs) { +export function loader({ params }: Route.LoaderArgs) { const { token } = params; if (!token) { - return { - state: 'InvalidLink', - } as const; + throw redirect('/'); } - const organisationMemberInvite = await prisma.organisationMemberInvite.findUnique({ - where: { - token, - }, - include: { - organisation: { - select: { - name: true, - }, - }, - }, - }); - - if (!organisationMemberInvite) { - return { - state: 'InvalidLink', - } as const; - } - - if (organisationMemberInvite.status !== OrganisationMemberInviteStatus.DECLINED) { - await prisma.organisationMemberInvite.update({ - where: { - id: organisationMemberInvite.id, - }, - data: { - status: OrganisationMemberInviteStatus.DECLINED, - }, - }); - } - - return { - state: 'Success', - organisationName: organisationMemberInvite.organisation.name, - } as const; -} - -export default function DeclineInvitationPage({ loaderData }: Route.ComponentProps) { - const data = loaderData; - - if (data.state === 'InvalidLink') { - return ( -
-
-

- Invalid token -

- -

- This token is invalid or has expired. No action is needed. -

- - -
-
- ); - } - - return ( -
-

- Invitation declined -

- -

- - You have declined the invitation from {data.organisationName} to join their organisation. - -

- - -
- ); + // Declining now happens on the invite page via tRPC. Redirect there with the + // `action=decline` flag so it renders the decline-only view (no accept). + throw redirect(`/organisation/invite/${token}?action=decline`); } diff --git a/apps/remix/app/routes/_unauthenticated+/organisation.invite.$token.tsx b/apps/remix/app/routes/_unauthenticated+/organisation.invite.$token.tsx index a95970774..5f6609d85 100644 --- a/apps/remix/app/routes/_unauthenticated+/organisation.invite.$token.tsx +++ b/apps/remix/app/routes/_unauthenticated+/organisation.invite.$token.tsx @@ -1,9 +1,15 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; -import { acceptOrganisationInvitation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation'; +import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { prisma } from '@documenso/prisma'; +import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; -import { Trans } from '@lingui/react/macro'; -import { Link } from 'react-router'; +import { useToast } from '@documenso/ui/primitives/use-toast'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { OrganisationMemberInviteStatus } from '@prisma/client'; +import { useState } from 'react'; +import { Link, useSearchParams } from 'react-router'; +import { match } from 'ts-pattern'; import type { Route } from './+types/organisation.invite.$token'; @@ -37,6 +43,22 @@ export async function loader({ params, request }: Route.LoaderArgs) { } as const; } + const organisationName = organisationMemberInvite.organisation.name; + + if (organisationMemberInvite.status === OrganisationMemberInviteStatus.ACCEPTED) { + return { + state: 'AlreadyAccepted', + organisationName, + } as const; + } + + if (organisationMemberInvite.status === OrganisationMemberInviteStatus.DECLINED) { + return { + state: 'AlreadyDeclined', + organisationName, + } as const; + } + const user = await prisma.user.findFirst({ where: { email: { @@ -49,26 +71,13 @@ export async function loader({ params, request }: Route.LoaderArgs) { }, }); - // Directly convert the team member invite to a team member if they already have an account. - if (user) { - await acceptOrganisationInvitation({ token: organisationMemberInvite.token }); - } - - if (!user) { - return { - state: 'LoginRequired', - email: organisationMemberInvite.email, - organisationName: organisationMemberInvite.organisation.name, - } as const; - } - - const isSessionUserTheInvitedUser = user.id === session.user?.id; - return { - state: 'Success', + state: 'Pending', + token: organisationMemberInvite.token, email: organisationMemberInvite.email, - organisationName: organisationMemberInvite.organisation.name, - isSessionUserTheInvitedUser, + organisationName, + userExists: user !== null, + isSessionUserTheInvitedUser: user !== null && user.id === session.user?.id, } as const; } @@ -97,57 +106,253 @@ export default function AcceptInvitationPage({ loaderData }: Route.ComponentProp ); } - if (data.state === 'LoginRequired') { + if (data.state === 'AlreadyAccepted') { return ( -
-

- Organisation invitation -

+
+
+

+ Invitation already accepted +

-

- - You have been invited by {data.organisationName} to join their organisation. - -

+

+ + You are already a member of {data.organisationName}. + +

-

- To accept this invitation you must create an account. -

- - + +
); } + if (data.state === 'AlreadyDeclined') { + return ; + } + return ( -
-

- Invitation accepted! -

- -

- - You have accepted an invitation from {data.organisationName} to join their organisation. - -

- - {data.isSessionUserTheInvitedUser ? ( - - ) : ( - - )} -
+ ); } + +type PendingInvitationProps = { + token: string; + email: string; + organisationName: string; + userExists: boolean; + isSessionUserTheInvitedUser: boolean; +}; + +type InvitationResult = 'idle' | 'accepted' | 'declined'; + +type AcceptFailureReason = 'CapExceeded' | 'SubscriptionInactive' | 'Unknown'; + +const PendingInvitation = ({ + token, + email, + organisationName, + userExists, + isSessionUserTheInvitedUser, +}: PendingInvitationProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + const { refreshSession } = useOptionalSession(); + + const [searchParams] = useSearchParams(); + const actionIsDecline = searchParams.get('action') === 'decline'; + + const [result, setResult] = useState('idle'); + const [acceptFailureReason, setAcceptFailureReason] = useState(null); + + const acceptInvitation = trpc.organisation.member.invite.accept.useMutation({ + onSuccess: async () => { + await refreshSession(); + + setResult('accepted'); + }, + onError: (err) => { + const error = AppError.parseError(err); + + const failureReason = match(error.code) + .with(AppErrorCode.LIMIT_EXCEEDED, () => 'CapExceeded' as const) + .with('SUBSCRIPTION_INACTIVE', () => 'SubscriptionInactive' as const) + .otherwise(() => 'Unknown' as const); + + setAcceptFailureReason(failureReason); + }, + }); + + const declineInvitation = trpc.organisation.member.invite.decline.useMutation({ + onSuccess: async () => { + await refreshSession(); + + setResult('declined'); + }, + onError: () => { + toast({ + title: t`Something went wrong`, + description: t`Unable to decline this invitation at this time.`, + variant: 'destructive', + duration: 10000, + }); + }, + }); + + if (result === 'accepted') { + return ( +
+
+

+ Invitation accepted! +

+ +

+ + You have accepted an invitation from {organisationName} to join their organisation. + +

+ + {isSessionUserTheInvitedUser ? ( + + ) : ( + + )} +
+
+ ); + } + + if (result === 'declined') { + return ; + } + + // Accepting requires an account (acceptance keys off the invited email). + // Declining does not, so we only gate account creation on the accept flow. + if (!actionIsDecline && !userExists) { + return ( +
+
+

+ Organisation invitation +

+ +

+ + You have been invited by {organisationName} to join their organisation. + +

+ +

+ To accept this invitation you must create an account. +

+ + +
+
+ ); + } + + const isPending = acceptInvitation.isPending || declineInvitation.isPending; + + return ( +
+
+

+ Organisation invitation +

+ +

+ + You have been invited to join {organisationName} on Documenso. + +

+ + {acceptFailureReason && ( +

+ {match(acceptFailureReason) + .with('CapExceeded', () => ( + + {organisationName} has reached its member limit. Please contact the organisation + administrator to upgrade their plan before accepting this invitation. + + )) + .with('SubscriptionInactive', () => ( + + {organisationName} does not have an active subscription. Please contact the + organisation administrator to renew their plan before accepting this invitation. + + )) + .with('Unknown', () => ( + + We were unable to add you to {organisationName} at this time. Please try again later, + or contact the organisation administrator. + + )) + .exhaustive()} +

+ )} + +
+ + + {!actionIsDecline && ( + + )} +
+
+
+ ); +}; + +const InvitationDeclined = ({ organisationName }: { organisationName: string }) => { + return ( +
+
+

+ Invitation declined +

+ +

+ + You have declined the invitation from {organisationName} to join their organisation. + +

+
+
+ ); +}; diff --git a/packages/ee/server-only/stripe/sync-stripe-customer-subscription.ts b/packages/ee/server-only/stripe/sync-stripe-customer-subscription.ts index 29a4d7b7f..2a9502e4e 100644 --- a/packages/ee/server-only/stripe/sync-stripe-customer-subscription.ts +++ b/packages/ee/server-only/stripe/sync-stripe-customer-subscription.ts @@ -5,6 +5,7 @@ import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription'; import { prisma } from '@documenso/prisma'; import { OrganisationType, type Prisma, SubscriptionStatus } from '@prisma/client'; import { match } from 'ts-pattern'; +import { reconcileSeatBasedPlans } from './update-subscription-item-quantity'; const LIVE_SUBSCRIPTION_STATUSES: Stripe.Subscription.Status[] = ['active', 'trialing', 'past_due']; @@ -229,6 +230,19 @@ const handleLiveSubscription = async ({ }); } }); + + // Detect a billing-period roll by comparing the persisted period end with + // the freshly-fetched one — the convergent equivalent of the old + // `previous_attributes.current_period_start` signal. On renewal, reconcile + // the seat quantity and claim down to the actual member count. The reconcile + // itself no-ops for non-seat/unlimited plans and inactive subscriptions. + const previousPeriodEnd = organisation.subscription?.periodEnd ?? null; + + const hasPeriodAdvanced = previousPeriodEnd !== null && periodEnd.getTime() > previousPeriodEnd.getTime(); + + if (hasPeriodAdvanced && !bypassClaimUpdate) { + await reconcileSeatBasedPlans(organisation.id); + } }; /** diff --git a/packages/ee/server-only/stripe/update-subscription-item-quantity.ts b/packages/ee/server-only/stripe/update-subscription-item-quantity.ts index 084f1b1f8..1183e0d35 100644 --- a/packages/ee/server-only/stripe/update-subscription-item-quantity.ts +++ b/packages/ee/server-only/stripe/update-subscription-item-quantity.ts @@ -3,6 +3,7 @@ import { stripe } from '@documenso/lib/server-only/stripe'; import { appLog } from '@documenso/lib/utils/debugger'; import { prisma } from '@documenso/prisma'; import type { OrganisationClaim, Subscription } from '@prisma/client'; +import { SubscriptionStatus } from '@prisma/client'; import type Stripe from 'stripe'; import { isPriceSeatsBased } from './is-price-seats-based'; @@ -11,12 +12,14 @@ export type UpdateSubscriptionItemQuantityOptions = { subscriptionId: string; quantity: number; priceId: string; + prorationBehaviour: 'always_invoice' | 'none'; }; export const updateSubscriptionItemQuantity = async ({ subscriptionId, quantity, priceId, + prorationBehaviour, }: UpdateSubscriptionItemQuantityOptions) => { const subscription = await stripe.subscriptions.retrieve(subscriptionId); @@ -26,7 +29,6 @@ export const updateSubscriptionItemQuantity = async ({ throw new Error('Subscription does not contain required item'); } - const hasYearlyItem = items.find((item) => item.price.recurring?.interval === 'year'); const oldQuantity = items[0].quantity; if (oldQuantity === quantity) { @@ -38,13 +40,12 @@ export const updateSubscriptionItemQuantity = async ({ id: item.id, quantity, })), + proration_behavior: prorationBehaviour, + // Need to "off_session" updates since adding 3DS will have payments + // not pass through for these immediate invoices. + off_session: true, }; - // Only invoice immediately when changing the quantity of yearly item. - if (hasYearlyItem) { - subscriptionUpdatePayload.proration_behavior = 'always_invoice'; - } - await stripe.subscriptions.update(subscriptionId, subscriptionUpdatePayload); }; @@ -55,15 +56,19 @@ export const updateSubscriptionItemQuantity = async ({ * via Stripe rather than enforcing a hard cap. A `memberCount` of `0` on the * organisation claim represents unlimited seats. * + * Organisations without a subscription (e.g. after being downgraded to the + * free plan) can pass `null`, in which case the claim cap is enforced + * directly without the seats-based exemption. + * * Should only be called from grow paths (invite/add). Reducing operations * must never be gated by this check. * - * @param subscription - The organisation's Stripe subscription. + * @param subscription - The organisation's Stripe subscription, if any. * @param organisationClaim - The organisation claim. - * @param quantity - The proposed total member + pending invite count. + * @param quantity - The proposed total member count. */ export const assertMemberCountWithinCap = async ( - subscription: Subscription, + subscription: Subscription | null, organisationClaim: OrganisationClaim, quantity: number, ) => { @@ -75,10 +80,12 @@ export const assertMemberCountWithinCap = async ( } // Seats-based plans don't have a hard cap; Stripe meters the usage. - const isSeatsBased = await isPriceSeatsBased(subscription.priceId); + if (subscription) { + const isSeatsBased = await isPriceSeatsBased(subscription.priceId); - if (isSeatsBased) { - return; + if (isSeatsBased) { + return; + } } if (quantity > maximumMemberCount) { @@ -89,48 +96,134 @@ export const assertMemberCountWithinCap = async ( }; /** - * Syncs the organisation's member count with the Stripe subscription quantity. + * Syncs the Stripe subscription quantity with the organisation's member count. * - * No-ops for plans that are not seats-based, and for organisations with - * unlimited seats (`organisationClaim.memberCount === 0`). Safe to call from - * both grow and shrink paths. + * This is a Stripe <-> Database sync operation. + * + * Note: `organisationClaim.memberCount` is the paid seat high-water mark for the + * current billing period — the highest count we've already billed for. * * @param subscription - The subscription to sync the member count with. * @param organisationClaim - The organisation claim. - * @param quantity - The new total member + pending invite count to sync. + * @param quantity - The new total member count to sync. + * @param mode - The member-count change that triggered the sync. */ export const syncMemberCountWithStripeSeatPlan = async ( subscription: Subscription, organisationClaim: OrganisationClaim, quantity: number, + mode: 'grow' | 'shrink', ) => { - // Infinite seats means no sync needed. + // Unlimited seats — nothing to meter. if (organisationClaim.memberCount === 0) { return; } const isSeatsBased = await isPriceSeatsBased(subscription.priceId); + // Only seat-based plans support seat syncing. if (!isSeatsBased) { return; } - appLog('BILLING', 'Updating seat based plan'); + // Whether to immediately invoice for new seats if the quantity is greater than + // the high-water mark. + const billsForNewSeats = mode === 'grow' && quantity > organisationClaim.memberCount; + + appLog('BILLING', `Syncing seat based plan (${mode}, quantity ${quantity})`); await updateSubscriptionItemQuantity({ priceId: subscription.priceId, subscriptionId: subscription.planId, quantity, + prorationBehaviour: billsForNewSeats ? 'always_invoice' : 'none', + }); + + // Advance the high-water mark when billing for new seats; it is reset to the + // actual member count when the billing period rolls over. Re-adds and shrinks + // deliberately leave it untouched so a seat already paid for this period is + // never re-charged. + if (billsForNewSeats) { + await prisma.organisationClaim.update({ + where: { + id: organisationClaim.id, + }, + data: { + memberCount: quantity, + }, + }); + } +}; + +/** + * Reconciles the organisation claim seat counter, and the stripe quantity with the + * actual member count. + * + * Uses the member count as the authoritative source of truth. Meaning: + * - Update the organisation claim with the member count + * - Update the Stripe subscription quantity to the member count + * + * This should only be called when the billing period rolls over. + */ +export const reconcileSeatBasedPlans = async (organisationId: string) => { + const organisation = await prisma.organisation.findFirst({ + where: { + id: organisationId, + }, + include: { + organisationClaim: true, + subscription: true, + }, + }); + + if (!organisation || !organisation.subscription) { + return; + } + + const { subscription, organisationClaim } = organisation; + + // Stripe rejects quantity updates on canceled subscriptions. PAST_DUE is + // still live and a no-proration sync is safe, so it's allowed through. + if (subscription.status === SubscriptionStatus.INACTIVE) { + return; + } + + // Unlimited seats — nothing to meter. + if (organisationClaim.memberCount === 0) { + return; + } + + const isSeatsBased = await isPriceSeatsBased(subscription.priceId); + + // Only seat-based plans support seat syncing. + if (!isSeatsBased) { + return; + } + + const memberCount = await prisma.organisationMember.count({ + where: { + organisationId, + }, + }); + + // An organisation always retains its owner; never write the unlimited sentinel. + if (memberCount === 0) { + return; + } + + await updateSubscriptionItemQuantity({ + priceId: subscription.priceId, + subscriptionId: subscription.planId, + quantity: memberCount, + prorationBehaviour: 'none', }); - // This should be automatically updated after the Stripe webhook is fired - // but we just manually adjust it here as well to avoid any race conditions. await prisma.organisationClaim.update({ where: { id: organisationClaim.id, }, data: { - memberCount: quantity, + memberCount, }, }); }; diff --git a/packages/ee/server-only/stripe/webhook/handler.ts b/packages/ee/server-only/stripe/webhook/handler.ts index 03f07daf3..538187c0f 100644 --- a/packages/ee/server-only/stripe/webhook/handler.ts +++ b/packages/ee/server-only/stripe/webhook/handler.ts @@ -2,7 +2,6 @@ import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import type { Stripe } from '@documenso/lib/server-only/stripe'; import { stripe } from '@documenso/lib/server-only/stripe'; import { env } from '@documenso/lib/utils/env'; - import { syncStripeCustomerSubscription } from '../sync-stripe-customer-subscription'; type StripeWebhookResponse = { diff --git a/packages/lib/jobs/client.ts b/packages/lib/jobs/client.ts index 397108f61..5309bd510 100644 --- a/packages/lib/jobs/client.ts +++ b/packages/lib/jobs/client.ts @@ -17,6 +17,7 @@ import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emai import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email'; import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email'; import { ADMIN_DELETE_ORGANISATION_JOB_DEFINITION } from './definitions/internal/admin-delete-organisation'; +import { ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION } from './definitions/internal/alert-organisation-seat-drift'; import { BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION } from './definitions/internal/backport-subscription-claims'; import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template'; import { CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION } from './definitions/internal/cancel-organisation-subscription'; @@ -29,6 +30,7 @@ import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-docume import { SEAL_DOCUMENT_SWEEP_JOB_DEFINITION } from './definitions/internal/seal-document-sweep'; import { SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION } from './definitions/internal/send-signing-reminders-sweep'; import { SYNC_EMAIL_DOMAINS_JOB_DEFINITION } from './definitions/internal/sync-email-domains'; +import { SYNC_ORGANISATION_SEATS_JOB_DEFINITION } from './definitions/internal/sync-organisation-seats'; /** * The `as const` assertion is load bearing as it provides the correct level of type inference for @@ -64,7 +66,9 @@ export const jobsClient = new JobClient([ CLEANUP_RATE_LIMITS_JOB_DEFINITION, SYNC_EMAIL_DOMAINS_JOB_DEFINITION, ADMIN_DELETE_ORGANISATION_JOB_DEFINITION, + ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION, CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION, + SYNC_ORGANISATION_SEATS_JOB_DEFINITION, ] as const); export const jobs = jobsClient; diff --git a/packages/lib/jobs/definitions/internal/alert-organisation-seat-drift.handler.ts b/packages/lib/jobs/definitions/internal/alert-organisation-seat-drift.handler.ts new file mode 100644 index 000000000..e8cd9f0e8 --- /dev/null +++ b/packages/lib/jobs/definitions/internal/alert-organisation-seat-drift.handler.ts @@ -0,0 +1,67 @@ +import { mailer } from '@documenso/email/mailer'; +import { prisma } from '@documenso/prisma'; +import { IS_BILLING_ENABLED, SUPPORT_EMAIL } from '../../../constants/app'; +import { DOCUMENSO_INTERNAL_EMAIL } from '../../../constants/email'; +import type { JobRunIO } from '../../client/_internal/job'; +import type { TAlertOrganisationSeatDriftJobDefinition } from './alert-organisation-seat-drift'; + +/** + * Daily check for organisations whose member count exceeds their paid seat + * count (`organisationClaim.memberCount`, where `0` means unlimited). + */ +export const run = async ({ io }: { payload: TAlertOrganisationSeatDriftJobDefinition; io: JobRunIO }) => { + if (!IS_BILLING_ENABLED()) { + return; + } + + const organisations = await prisma.organisation.findMany({ + where: { + // Exclude unlimited-seat plans (memberCount === 0). + organisationClaim: { + memberCount: { + not: 0, + }, + }, + }, + select: { + id: true, + name: true, + organisationClaim: { + select: { + memberCount: true, + }, + }, + _count: { + select: { + members: true, + }, + }, + }, + }); + + const driftedOrganisations = organisations.filter( + (organisation) => + organisation.organisationClaim !== null && + organisation._count.members > organisation.organisationClaim.memberCount, + ); + + if (driftedOrganisations.length === 0) { + io.logger.info('No organisations exceed their paid seat count'); + + return; + } + + await mailer.sendMail({ + to: SUPPORT_EMAIL, + from: DOCUMENSO_INTERNAL_EMAIL, + subject: `[Billing] ${driftedOrganisations.length} organisation(s) exceed their paid seat count`, + text: [ + `${driftedOrganisations.length} organisation(s) have more members than their paid seat count:`, + '', + ...driftedOrganisations.map( + (organisation) => + `- ${organisation.name} (${organisation.id}): ${organisation._count.members} members vs ${organisation.organisationClaim?.memberCount ?? 0} paid seats`, + ), + ].join('\n'), + }); +}; diff --git a/packages/lib/jobs/definitions/internal/alert-organisation-seat-drift.ts b/packages/lib/jobs/definitions/internal/alert-organisation-seat-drift.ts new file mode 100644 index 000000000..7cf5a3a5d --- /dev/null +++ b/packages/lib/jobs/definitions/internal/alert-organisation-seat-drift.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +import type { JobDefinition } from '../../client/_internal/job'; + +const ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_ID = 'internal.alert-organisation-seat-drift'; + +const ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_SCHEMA = z.object({}); + +export type TAlertOrganisationSeatDriftJobDefinition = z.infer< + typeof ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_SCHEMA +>; + +export const ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION = { + id: ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_ID, + name: 'Alert Organisation Seat Drift', + version: '1.0.0', + trigger: { + name: ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_ID, + schema: ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_SCHEMA, + cron: '0 0 * * *', // Once a day at midnight. + }, + handler: async ({ payload, io }) => { + const handler = await import('./alert-organisation-seat-drift.handler'); + + await handler.run({ payload, io }); + }, +} as const satisfies JobDefinition< + typeof ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_ID, + TAlertOrganisationSeatDriftJobDefinition +>; diff --git a/packages/lib/jobs/definitions/internal/sync-organisation-seats.handler.ts b/packages/lib/jobs/definitions/internal/sync-organisation-seats.handler.ts new file mode 100644 index 000000000..9010e4d86 --- /dev/null +++ b/packages/lib/jobs/definitions/internal/sync-organisation-seats.handler.ts @@ -0,0 +1,54 @@ +import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity'; +import { prisma } from '@documenso/prisma'; +import { SubscriptionStatus } from '@prisma/client'; +import { IS_BILLING_ENABLED } from '../../../constants/app'; +import type { JobRunIO } from '../../client/_internal/job'; +import type { TSyncOrganisationSeatsJobDefinition } from './sync-organisation-seats'; + +export const run = async ({ payload }: { payload: TSyncOrganisationSeatsJobDefinition; io: JobRunIO }) => { + const { organisationId } = payload; + + if (!IS_BILLING_ENABLED()) { + return; + } + + const organisation = await prisma.organisation.findUnique({ + where: { + id: organisationId, + }, + include: { + subscription: true, + organisationClaim: true, + }, + }); + + if (!organisation || !organisation.subscription) { + return; + } + + // Skip canceled/terminal subscriptions — Stripe rejects quantity updates on a + // canceled subscription. PAST_DUE is still live and a no-proration shrink is + // safe, so it's allowed through. + if (organisation.subscription.status === SubscriptionStatus.INACTIVE) { + return; + } + + const memberCount = await prisma.organisationMember.count({ + where: { + organisationId, + }, + }); + + // An organisation always retains its owner; guarding zero avoids writing the + // unlimited sentinel to the claim. + if (memberCount === 0) { + return; + } + + await syncMemberCountWithStripeSeatPlan( + organisation.subscription, + organisation.organisationClaim, + memberCount, + 'shrink', + ); +}; diff --git a/packages/lib/jobs/definitions/internal/sync-organisation-seats.ts b/packages/lib/jobs/definitions/internal/sync-organisation-seats.ts new file mode 100644 index 000000000..7ff4b6629 --- /dev/null +++ b/packages/lib/jobs/definitions/internal/sync-organisation-seats.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +import type { JobDefinition } from '../../client/_internal/job'; + +const SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID = 'internal.sync-organisation-seats'; + +const SYNC_ORGANISATION_SEATS_JOB_DEFINITION_SCHEMA = z.object({ + organisationId: z.string(), +}); + +export type TSyncOrganisationSeatsJobDefinition = z.infer; + +export const SYNC_ORGANISATION_SEATS_JOB_DEFINITION = { + id: SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID, + name: 'Sync Organisation Seats', + version: '1.0.0', + trigger: { + name: SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID, + schema: SYNC_ORGANISATION_SEATS_JOB_DEFINITION_SCHEMA, + }, + handler: async ({ payload, io }) => { + const handler = await import('./sync-organisation-seats.handler'); + + await handler.run({ payload, io }); + }, +} as const satisfies JobDefinition< + typeof SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID, + TSyncOrganisationSeatsJobDefinition +>; diff --git a/packages/lib/server-only/organisation/accept-organisation-invitation.ts b/packages/lib/server-only/organisation/accept-organisation-invitation.ts index 67b8f7643..130b5bbec 100644 --- a/packages/lib/server-only/organisation/accept-organisation-invitation.ts +++ b/packages/lib/server-only/organisation/accept-organisation-invitation.ts @@ -1,7 +1,12 @@ +import { + assertMemberCountWithinCap, + syncMemberCountWithStripeSeatPlan, +} from '@documenso/ee/server-only/stripe/update-subscription-item-quantity'; import { prisma } from '@documenso/prisma'; import type { OrganisationGroup, OrganisationMemberRole } from '@prisma/client'; -import { OrganisationGroupType, OrganisationMemberInviteStatus } from '@prisma/client'; +import { OrganisationGroupType, OrganisationMemberInviteStatus, SubscriptionStatus } from '@prisma/client'; +import { IS_BILLING_ENABLED } from '../../constants/app'; import { AppError, AppErrorCode } from '../../errors/app-error'; import { jobs } from '../../jobs/client'; import { generateDatabaseId } from '../../universal/id'; @@ -22,6 +27,13 @@ export const acceptOrganisationInvitation = async ({ token }: AcceptOrganisation organisation: { include: { groups: true, + organisationClaim: true, + subscription: true, + members: { + select: { + id: true, + }, + }, }, }, }, @@ -66,6 +78,35 @@ export const acceptOrganisationInvitation = async ({ token }: AcceptOrganisation return; } + const newMemberCount = organisation.members.length + 1; + + // Billing occurs when a user accepts an invite. + // Assert that the new member count is within the cap and sync the seat plan with Stripe. + if (IS_BILLING_ENABLED()) { + const { subscription, organisationClaim } = organisation; + + // A canceled subscription cannot have its seat quantity updated in Stripe, + // and an organisation with lapsed billing should not gain new members. + // Throw a deliberate error so the invite page can render an accurate + // message instead of an opaque Stripe failure. + if (subscription && subscription.status === SubscriptionStatus.INACTIVE) { + throw new AppError('SUBSCRIPTION_INACTIVE', { + message: 'The organisation subscription is inactive', + }); + } + + // Organisations can exist without a subscription (e.g. after being + // downgraded to the free plan). The claim cap remains authoritative in + // that case, surfacing LIMIT_EXCEEDED instead of an opaque "subscription + // not found" error. + await assertMemberCountWithinCap(subscription, organisationClaim, newMemberCount); + + if (subscription) { + await syncMemberCountWithStripeSeatPlan(subscription, organisationClaim, newMemberCount, 'grow'); + } + } + + // Todo: Logging await addUserToOrganisation({ userId: user.id, organisationId: organisation.id, diff --git a/packages/lib/server-only/organisation/create-organisation-member-invites.ts b/packages/lib/server-only/organisation/create-organisation-member-invites.ts index 5e813e5b0..a31aafe6a 100644 --- a/packages/lib/server-only/organisation/create-organisation-member-invites.ts +++ b/packages/lib/server-only/organisation/create-organisation-member-invites.ts @@ -1,7 +1,3 @@ -import { - assertMemberCountWithinCap, - syncMemberCountWithStripeSeatPlan, -} from '@documenso/ee/server-only/stripe/update-subscription-item-quantity'; import { OrganisationInviteEmailTemplate } from '@documenso/email/templates/organisation-invite'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations'; @@ -17,7 +13,6 @@ import { createElement } from 'react'; import { getI18nInstance } from '../../client-only/providers/i18n-server'; import { generateDatabaseId } from '../../universal/id'; -import { validateIfSubscriptionIsRequired } from '../../utils/billing'; import { buildOrganisationWhereQuery } from '../../utils/organisations'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { getEmailContext } from '../email/get-email-context'; @@ -62,8 +57,6 @@ export const createOrganisationMemberInvites = async ({ }, }, organisationGlobalSettings: true, - organisationClaim: true, - subscription: true, }, }); @@ -71,10 +64,6 @@ export const createOrganisationMemberInvites = async ({ throw new AppError(AppErrorCode.NOT_FOUND); } - const { organisationClaim } = organisation; - - const subscription = validateIfSubscriptionIsRequired(organisation.subscription); - const currentOrganisationMemberRole = await getMemberOrganisationRole({ organisationId: organisation.id, reference: { @@ -120,19 +109,6 @@ export const createOrganisationMemberInvites = async ({ }), ); - const numberOfCurrentMembers = organisation.members.length; - const numberOfCurrentInvites = organisation.invites.length; - const numberOfNewInvites = organisationMemberInvites.length; - - const totalMemberCountWithInvites = numberOfCurrentMembers + numberOfCurrentInvites + numberOfNewInvites; - - // Enforce the seat cap and sync billing for seat based plans. - if (subscription) { - await assertMemberCountWithinCap(subscription, organisationClaim, totalMemberCountWithInvites); - - await syncMemberCountWithStripeSeatPlan(subscription, organisationClaim, totalMemberCountWithInvites); - } - await prisma.organisationMemberInvite.createMany({ data: organisationMemberInvites, }); diff --git a/packages/lib/server-only/user/delete-user.ts b/packages/lib/server-only/user/delete-user.ts index 2ad28d42c..03d97a0e3 100644 --- a/packages/lib/server-only/user/delete-user.ts +++ b/packages/lib/server-only/user/delete-user.ts @@ -1,6 +1,7 @@ import { prisma } from '@documenso/prisma'; import { AppError, AppErrorCode } from '../../errors/app-error'; +import { jobs } from '../../jobs/client'; import { deleteOrganisation } from '../organisation/delete-organisation'; export type DeleteUserOptions = { @@ -59,6 +60,13 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => { })), ); + // Organisations the user is a member of (but not owner). Owned organisations + // are fully torn down below (including subscription cancellation), so only + // these need a seat sync after the user's memberships cascade away. + const memberOrganisationIds = user.organisationMember + .filter((member) => member.organisation.ownerUserId !== user.id) + .map((member) => member.organisationId); + // For organisations the user owns - fully tear them down (orphan envelopes, // delete the organisation, and cancel any Stripe subscription). Without this // the organisations would only cascade away when the user row is deleted, @@ -82,9 +90,21 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => { }), ); - return await prisma.user.delete({ + const deletedUser = await prisma.user.delete({ where: { id: user.id, }, }); + + // The user's memberships were cascade-deleted with the user row — queue a + // seat sync for each organisation they belonged to so the Stripe quantity + // trues down to the new member count (no proration, no credit). + for (const organisationId of memberOrganisationIds) { + await jobs.triggerJob({ + name: 'internal.sync-organisation-seats', + payload: { organisationId }, + }); + } + + return deletedUser; }; diff --git a/packages/trpc/server/admin-router/delete-organisation-member.ts b/packages/trpc/server/admin-router/delete-organisation-member.ts index 07991d2d3..df66ac024 100644 --- a/packages/trpc/server/admin-router/delete-organisation-member.ts +++ b/packages/trpc/server/admin-router/delete-organisation-member.ts @@ -1,8 +1,6 @@ -import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { jobs } from '@documenso/lib/jobs/client'; import { prisma } from '@documenso/prisma'; -import { OrganisationMemberInviteStatus } from '@prisma/client'; import { adminProcedure } from '../trpc'; import { @@ -28,8 +26,6 @@ export const deleteAdminOrganisationMemberRoute = adminProcedure id: organisationId, }, include: { - subscription: true, - organisationClaim: true, teams: { select: { id: true, @@ -41,14 +37,6 @@ export const deleteAdminOrganisationMemberRoute = adminProcedure userId: true, }, }, - invites: { - where: { - status: OrganisationMemberInviteStatus.PENDING, - }, - select: { - id: true, - }, - }, }, }); @@ -72,18 +60,6 @@ export const deleteAdminOrganisationMemberRoute = adminProcedure }); } - const newMemberCount = organisation.members.length + organisation.invites.length - 1; - - // Removing a member is a reducing operation, so we don't gate it on the - // subscription being present. Sync Stripe only when one exists. - if (organisation.subscription) { - await syncMemberCountWithStripeSeatPlan( - organisation.subscription, - organisation.organisationClaim, - newMemberCount, - ); - } - const teamIds = organisation.teams.map((team) => team.id); await prisma.$transaction(async (tx) => { @@ -113,6 +89,13 @@ export const deleteAdminOrganisationMemberRoute = adminProcedure }); }); + // A member was removed — queue a seat sync to true the Stripe quantity down + // to the new count (no proration, no credit). + await jobs.triggerJob({ + name: 'internal.sync-organisation-seats', + payload: { organisationId }, + }); + await jobs.triggerJob({ name: 'send.organisation-member-left.email', payload: { diff --git a/packages/trpc/server/organisation-router/delete-organisation-member-invites.ts b/packages/trpc/server/organisation-router/delete-organisation-member-invites.ts index 9d12d2ca7..834239270 100644 --- a/packages/trpc/server/organisation-router/delete-organisation-member-invites.ts +++ b/packages/trpc/server/organisation-router/delete-organisation-member-invites.ts @@ -1,4 +1,3 @@ -import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity'; import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { getMemberOrganisationRole } from '@documenso/lib/server-only/team/get-member-roles'; @@ -32,20 +31,6 @@ export const deleteOrganisationMemberInvitesRoute = authenticatedProcedure userId, roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'], }), - include: { - organisationClaim: true, - subscription: true, - members: { - select: { - id: true, - }, - }, - invites: { - select: { - id: true, - }, - }, - }, }); if (!organisation) { @@ -83,22 +68,6 @@ export const deleteOrganisationMemberInvitesRoute = authenticatedProcedure }); } - const { organisationClaim } = organisation; - - const numberOfCurrentMembers = organisation.members.length; - const numberOfCurrentInvites = organisation.invites.length; - const totalMemberCountWithInvites = numberOfCurrentMembers + numberOfCurrentInvites - 1; - - // Removing pending invites is a reducing operation, so we don't gate it on - // the subscription being present. Sync Stripe only when one exists. - if (organisation.subscription) { - await syncMemberCountWithStripeSeatPlan( - organisation.subscription, - organisationClaim, - totalMemberCountWithInvites, - ); - } - await prisma.organisationMemberInvite.deleteMany({ where: { id: { diff --git a/packages/trpc/server/organisation-router/delete-organisation-members.ts b/packages/trpc/server/organisation-router/delete-organisation-members.ts index d19e3b71b..6a436ddff 100644 --- a/packages/trpc/server/organisation-router/delete-organisation-members.ts +++ b/packages/trpc/server/organisation-router/delete-organisation-members.ts @@ -1,8 +1,8 @@ -import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity'; import { ORGANISATION_MEMBER_ROLE_HIERARCHY, ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP, } from '@documenso/lib/constants/organisations'; + import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { jobs } from '@documenso/lib/jobs/client'; import { @@ -11,7 +11,6 @@ import { isOrganisationRoleWithinUserHierarchy, } from '@documenso/lib/utils/organisations'; import { prisma } from '@documenso/prisma'; -import { OrganisationMemberInviteStatus } from '@documenso/prisma/client'; import { authenticatedProcedure } from '../trpc'; import { @@ -59,8 +58,6 @@ export const deleteOrganisationMembers = async ({ roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'], }), include: { - subscription: true, - organisationClaim: true, teams: { select: { id: true, @@ -75,14 +72,6 @@ export const deleteOrganisationMembers = async ({ }, }, }, - invites: { - where: { - status: OrganisationMemberInviteStatus.PENDING, - }, - select: { - id: true, - }, - }, }, }); @@ -90,8 +79,6 @@ export const deleteOrganisationMembers = async ({ throw new AppError(AppErrorCode.UNAUTHORIZED); } - const { organisationClaim } = organisation; - const membersToDelete = organisation.members.filter((member) => organisationMemberIds.includes(member.id)); const currentUserMember = organisation.members.find((member) => member.userId === userId); @@ -129,15 +116,6 @@ export const deleteOrganisationMembers = async ({ } } - const inviteCount = organisation.invites.length; - const newMemberCount = organisation.members.length + inviteCount - membersToDelete.length; - - // Removing members is a reducing operation, so we don't gate it on the - // subscription being present. Sync Stripe only when one exists. - if (organisation.subscription) { - await syncMemberCountWithStripeSeatPlan(organisation.subscription, organisationClaim, newMemberCount); - } - const removedUserIds = membersToDelete.map((member) => member.userId); const teamIds = organisation.teams.map((team) => team.id); @@ -184,6 +162,13 @@ export const deleteOrganisationMembers = async ({ }); }); + // Members were removed — queue a seat sync to true the Stripe quantity down to + // the new count (no proration, no credit). + await jobs.triggerJob({ + name: 'internal.sync-organisation-seats', + payload: { organisationId }, + }); + for (const member of membersToDelete) { await jobs.triggerJob({ name: 'send.organisation-member-left.email', diff --git a/packages/trpc/server/organisation-router/leave-organisation.ts b/packages/trpc/server/organisation-router/leave-organisation.ts index 8e2b8b775..466870f41 100644 --- a/packages/trpc/server/organisation-router/leave-organisation.ts +++ b/packages/trpc/server/organisation-router/leave-organisation.ts @@ -1,9 +1,7 @@ -import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { jobs } from '@documenso/lib/jobs/client'; import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations'; import { prisma } from '@documenso/prisma'; -import { OrganisationMemberInviteStatus } from '@documenso/prisma/client'; import { authenticatedProcedure } from '../trpc'; import { ZLeaveOrganisationRequestSchema, ZLeaveOrganisationResponseSchema } from './leave-organisation.types'; @@ -24,26 +22,11 @@ export const leaveOrganisationRoute = authenticatedProcedure const organisation = await prisma.organisation.findFirst({ where: buildOrganisationWhereQuery({ organisationId, userId }), include: { - organisationClaim: true, - subscription: true, teams: { select: { id: true, }, }, - invites: { - where: { - status: OrganisationMemberInviteStatus.PENDING, - }, - select: { - id: true, - }, - }, - members: { - select: { - id: true, - }, - }, }, }); @@ -59,17 +42,6 @@ export const leaveOrganisationRoute = authenticatedProcedure }); } - const { organisationClaim } = organisation; - - const inviteCount = organisation.invites.length; - const newMemberCount = organisation.members.length + inviteCount - 1; - - // Leaving is a reducing operation, so we don't gate it on the subscription - // being present. Sync Stripe only when one exists. - if (organisation.subscription) { - await syncMemberCountWithStripeSeatPlan(organisation.subscription, organisationClaim, newMemberCount); - } - const teamIds = organisation.teams.map((team) => team.id); await prisma.$transaction(async (tx) => { @@ -101,6 +73,13 @@ export const leaveOrganisationRoute = authenticatedProcedure }); }); + // A member was removed — queue a seat sync to true the Stripe quantity down + // to the new count (no proration, no credit). + await jobs.triggerJob({ + name: 'internal.sync-organisation-seats', + payload: { organisationId }, + }); + await jobs.triggerJob({ name: 'send.organisation-member-left.email', payload: { From c02dfaba1a89f346db785879d39d35a04ec3450b Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 23 Jul 2026 13:16:44 +0900 Subject: [PATCH 08/27] feat: rework command search (#3109) --- .../components/general/app-command-menu.tsx | 970 ++++++++++++++---- .../general/app-command-menu.types.ts | 36 + .../general/use-admin-search-categories.ts | 144 +++ .../app-tests/e2e/admin/global-search.spec.ts | 439 ++++++++ .../e2e/api/trpc/admin/admin-search.spec.ts | 249 +++++ .../e2e/command-menu/document-search.spec.ts | 15 +- .../app-tests/e2e/fixtures/command-menu.ts | 18 + .../server-only/admin/admin-global-search.ts | 372 +++++++ .../trpc/server/admin-router/admin-search.ts | 15 + .../server/admin-router/admin-search.types.ts | 37 + packages/trpc/server/admin-router/router.ts | 2 + 11 files changed, 2064 insertions(+), 233 deletions(-) create mode 100644 apps/remix/app/components/general/app-command-menu.types.ts create mode 100644 apps/remix/app/components/general/use-admin-search-categories.ts create mode 100644 packages/app-tests/e2e/admin/global-search.spec.ts create mode 100644 packages/app-tests/e2e/api/trpc/admin/admin-search.spec.ts create mode 100644 packages/app-tests/e2e/fixtures/command-menu.ts create mode 100644 packages/lib/server-only/admin/admin-global-search.ts create mode 100644 packages/trpc/server/admin-router/admin-search.ts create mode 100644 packages/trpc/server/admin-router/admin-search.types.ts diff --git a/apps/remix/app/components/general/app-command-menu.tsx b/apps/remix/app/components/general/app-command-menu.tsx index dc3268673..9746929b0 100644 --- a/apps/remix/app/components/general/app-command-menu.tsx +++ b/apps/remix/app/components/general/app-command-menu.tsx @@ -8,48 +8,66 @@ import { } from '@documenso/lib/constants/keyboard-shortcuts'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc'; import { dynamicActivate } from '@documenso/lib/utils/i18n'; -import { isPersonalLayout } from '@documenso/lib/utils/organisations'; import { trpc as trpcReact } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; -import { - CommandDialog, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - CommandShortcut, -} from '@documenso/ui/primitives/command'; +import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@documenso/ui/primitives/command'; +import { Dialog, DialogContent } from '@documenso/ui/primitives/dialog'; import { useToast } from '@documenso/ui/primitives/use-toast'; import type { MessageDescriptor } from '@lingui/core'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { keepPreviousData } from '@tanstack/react-query'; -import { CheckIcon, Loader, Monitor, Moon, Sun } from 'lucide-react'; -import { useCallback, useMemo, useState } from 'react'; +import { commandScore } from 'cmdk/dist/command-score'; +import { + ArrowLeftIcon, + CheckIcon, + CornerDownLeftIcon, + FileTextIcon, + GlobeIcon, + KeyRoundIcon, + LanguagesIcon, + LayoutTemplateIcon, + LoaderIcon, + MonitorIcon, + MoonIcon, + PaletteIcon, + SettingsIcon, + SunIcon, + UserIcon, +} from 'lucide-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; -import { useNavigate } from 'react-router'; +import { Link, useNavigate } from 'react-router'; import { Theme, useTheme } from 'remix-themes'; +import { match } from 'ts-pattern'; import { useOptionalCurrentTeam } from '~/providers/team'; -const SETTINGS_PAGES = [ - { - label: msg`Settings`, - path: '/settings', - shortcut: SETTINGS_PAGE_SHORTCUT.replace('+', ''), - }, - { label: msg`Profile`, path: '/settings/profile' }, - { label: msg`Password`, path: '/settings/password' }, -]; +import type { PromptCategory, PromptItem } from './app-command-menu.types'; +import { useAdminSearchCategories } from './use-admin-search-categories'; + +/** + * The maximum number of results the personal document/template searches return. + */ +const PERSONAL_SEARCH_RESULTS_CAP = 20; + +/** + * The minimum score for a hardcoded item to count as a fuzzy match. + * + * Prevents searches like "pass" showing "Templates" + */ +const MIN_FUZZY_SCORE = 0.1; + +const PROMPT_GROUP_CLASSNAME = + 'border-0 p-0 pt-1 [&_[cmdk-group-heading]]:mt-0 [&_[cmdk-group-heading]]:px-2.5 [&_[cmdk-group-heading]]:pt-2 [&_[cmdk-group-heading]]:pb-1 [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:text-[11px] [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-[0.07em] [&_[cmdk-group-heading]]:opacity-100'; export type AppCommandMenuProps = { open?: boolean; onOpenChange?: (_open: boolean) => void; }; -export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) { +export const AppCommandMenu = ({ open, onOpenChange }: AppCommandMenuProps) => { const { _ } = useLingui(); const { organisations } = useSession(); @@ -58,18 +76,42 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) { const [isOpen, setIsOpen] = useState(() => open ?? false); const [search, setSearch] = useState(''); - const [pages, setPages] = useState([]); + const [activePage, setActivePage] = useState<'theme' | 'language' | null>(null); + const [activeChip, setActiveChip] = useState('all'); + const [commandValue, setCommandValue] = useState(''); + + // Support both controlled and uncontrolled usage. + const isPromptOpen = open ?? isOpen; const debouncedSearch = useDebouncedValue(search, 200); - const hasValidSearch = debouncedSearch.trim().length > 0; + const trimmedSearch = debouncedSearch.trim(); - const { data: searchDocumentsData, isFetching: isFetchingDocuments } = trpcReact.document.search.useQuery( + // cmdk keeps a stale selection value behind when the entire result list is + // replaced, which prevents it from auto selecting the first new result. + // Controlling the value and clearing it whenever the query changes makes + // cmdk reliably select the first item once the new results register. + useEffect(() => { + setCommandValue(''); + }, [debouncedSearch, activePage]); + + const hasValidSearch = trimmedSearch.length > 0; + + const { + data: searchDocumentsData, + isFetching: isFetchingDocuments, + isError: isDocumentsSearchError, + } = trpcReact.document.search.useQuery( { query: debouncedSearch, }, { - enabled: open === true && hasValidSearch, + // Sub pages filter their own local lists, so the searches pause while + // one is open. + enabled: isPromptOpen && activePage === null && hasValidSearch, placeholderData: keepPreviousData, + // Show immediate failure instead of a long spinner. + retry: false, + // Do not batch this due to relatively long request time compared to // other queries which are generally batched with this. ...SKIP_QUERY_BATCH_META, @@ -77,272 +119,730 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) { }, ); - const { data: searchTemplatesData, isFetching: isFetchingTemplates } = trpcReact.template.search.useQuery( + const { + data: searchTemplatesData, + isFetching: isFetchingTemplates, + isError: isTemplatesSearchError, + } = trpcReact.template.search.useQuery( { query: debouncedSearch, }, { - enabled: open === true && hasValidSearch, + enabled: isPromptOpen && activePage === null && hasValidSearch, placeholderData: keepPreviousData, + retry: false, ...SKIP_QUERY_BATCH_META, ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, }, ); - const teamUrl = useMemo(() => { - let teamUrl = currentTeam?.url || null; + const { + isUserAdmin, + categories: adminSearchCategories, + isFetching: isFetchingAdminSearch, + isError: isAdminSearchError, + } = useAdminSearchCategories({ + query: trimmedSearch, + open: isPromptOpen && activePage === null, + }); - if (!teamUrl && isPersonalLayout(organisations)) { - teamUrl = organisations[0].teams[0]?.url || null; + // Hide the page scrollbar while the prompt is open. Radix's scroll lock + // blocks wheel and touch scrolling, but the page scrolls on the root + // element so its scrollbar stays visible and draggable. + useEffect(() => { + if (!isPromptOpen) { + return; } - return teamUrl; - }, [currentTeam, organisations]); + const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; - const documentPageLinks = useMemo(() => { - if (!teamUrl) { - return []; + const previousOverflow = document.documentElement.style.overflow; + const previousPaddingRight = document.body.style.paddingRight; + + document.documentElement.style.overflow = 'hidden'; + + // Compensate for the removed scrollbar so the page doesn't shift. + if (scrollbarWidth > 0) { + document.body.style.paddingRight = `${scrollbarWidth}px`; } - return [ - { - label: msg`All documents`, - path: `/t/${teamUrl}/documents?status=ALL`, - shortcut: DOCUMENTS_PAGE_SHORTCUT.replace('+', ''), - }, - { - label: msg`Draft documents`, - path: `/t/${teamUrl}/documents?status=DRAFT`, - }, - { - label: msg`Completed documents`, - path: `/t/${teamUrl}/documents?status=COMPLETED`, - }, - { - label: msg`Pending documents`, - path: `/t/${teamUrl}/documents?status=PENDING`, - }, - { - label: msg`Inbox documents`, - path: `/t/${teamUrl}/documents?status=INBOX`, - }, - ]; - }, [currentTeam, organisations]); - - const templatePageLinks = useMemo(() => { - if (!teamUrl) { - return []; - } - - return [ - { - label: msg`All templates`, - path: `/t/${teamUrl}/templates`, - shortcut: TEMPLATES_PAGE_SHORTCUT.replace('+', ''), - }, - ]; - }, [currentTeam, organisations]); - - const documentSearchResults = - hasValidSearch && searchDocumentsData - ? searchDocumentsData.map((document) => ({ - label: document.title, - path: document.path, - value: document.value, - })) - : []; - - const templateSearchResults = - hasValidSearch && searchTemplatesData - ? searchTemplatesData.map((template) => ({ - label: template.title, - path: template.path, - value: template.value, - })) - : []; - - const currentPage = pages[pages.length - 1]; - - const toggleOpen = () => { - setIsOpen((isOpen) => !isOpen); - onOpenChange?.(!isOpen); - - if (isOpen) { - setPages([]); - setSearch(''); - } - }; + return () => { + document.documentElement.style.overflow = previousOverflow; + document.body.style.paddingRight = previousPaddingRight; + }; + }, [isPromptOpen]); const setOpen = useCallback( - (open: boolean) => { - setIsOpen(open); - onOpenChange?.(open); + (nextOpen: boolean) => { + setIsOpen(nextOpen); + onOpenChange?.(nextOpen); - if (!open) { - setPages([]); + if (!nextOpen) { + setActivePage(null); + setActiveChip('all'); setSearch(''); + setCommandValue(''); } }, [onOpenChange], ); + const toggleOpen = () => { + setOpen(!isPromptOpen); + }; + const push = useCallback( (path: string) => { void navigate(path); setOpen(false); }, - [setOpen], + [navigate, setOpen], ); - const addPage = (page: string) => { - setPages((pages) => [...pages, page]); + const goToPage = useCallback((page: 'theme' | 'language') => { + setActivePage(page); setSearch(''); - }; + }, []); - const goToSettings = useCallback(() => push(SETTINGS_PAGES[0].path), [push]); - const goToDocuments = useCallback(() => push(documentPageLinks[0].path), [push]); - const goToTemplates = useCallback(() => push(templatePageLinks[0].path), [push]); + const resolveItemLabel = useCallback( + (label: string | MessageDescriptor) => (typeof label === 'string' ? label : _(label)), + [_], + ); + + // Fall back to the first available team so the default view always shows + // the document/template page links, even outside a team context such as the + // admin pages. + const teamUrl = useMemo( + () => currentTeam?.url || organisations[0]?.teams[0]?.url || null, + [currentTeam, organisations], + ); + + // Fuzzy match and rank the hardcoded items using the same scorer cmdk uses + // internally, so abbreviations like "setg" still match "Settings". + const filterBySearch = useCallback( + (items: PromptItem[]) => { + if (!hasValidSearch) { + return items; + } + + return items + .map((item) => ({ item, score: commandScore(resolveItemLabel(item.label), trimmedSearch) })) + .filter(({ score }) => score >= MIN_FUZZY_SCORE) + .sort((a, b) => b.score - a.score) + .map(({ item }) => item); + }, + [hasValidSearch, trimmedSearch, resolveItemLabel], + ); + + const categories = useMemo(() => { + const documentPageLinks: PromptItem[] = teamUrl + ? [ + { + id: 'documents-all', + label: msg`All documents`, + path: `/t/${teamUrl}/documents?status=ALL`, + icon: FileTextIcon, + shortcut: DOCUMENTS_PAGE_SHORTCUT.replace('+', ''), + }, + { + id: 'documents-draft', + label: msg`Draft documents`, + path: `/t/${teamUrl}/documents?status=DRAFT`, + icon: FileTextIcon, + }, + { + id: 'documents-completed', + label: msg`Completed documents`, + path: `/t/${teamUrl}/documents?status=COMPLETED`, + icon: FileTextIcon, + }, + { + id: 'documents-pending', + label: msg`Pending documents`, + path: `/t/${teamUrl}/documents?status=PENDING`, + icon: FileTextIcon, + }, + { + id: 'documents-inbox', + label: msg`Inbox documents`, + path: `/t/${teamUrl}/documents?status=INBOX`, + icon: FileTextIcon, + }, + ] + : []; + + const templatePageLinks: PromptItem[] = teamUrl + ? [ + { + id: 'templates-all', + label: msg`All templates`, + path: `/t/${teamUrl}/templates`, + icon: LayoutTemplateIcon, + shortcut: TEMPLATES_PAGE_SHORTCUT.replace('+', ''), + }, + ] + : []; + + const settingsLinks: PromptItem[] = [ + { + id: 'settings-main', + label: msg`Settings`, + path: '/settings', + icon: SettingsIcon, + shortcut: SETTINGS_PAGE_SHORTCUT.replace('+', ''), + }, + { id: 'settings-profile', label: msg`Profile`, path: '/settings/profile', icon: UserIcon }, + { id: 'settings-password', label: msg`Password`, path: '/settings/security', icon: KeyRoundIcon }, + { + id: 'settings-language', + label: msg`Change language`, + icon: LanguagesIcon, + onAction: () => goToPage('language'), + }, + { id: 'settings-theme', label: msg`Change theme`, icon: PaletteIcon, onAction: () => goToPage('theme') }, + ]; + + const personalDocumentItems: PromptItem[] = + hasValidSearch && searchDocumentsData + ? searchDocumentsData.map((document) => ({ + id: `personal-document-${document.path}`, + label: document.title, + path: document.path, + icon: FileTextIcon, + })) + : []; + + const personalTemplateItems: PromptItem[] = + hasValidSearch && searchTemplatesData + ? searchTemplatesData.map((template) => ({ + id: `personal-template-${template.path}`, + label: template.title, + path: template.path, + icon: LayoutTemplateIcon, + })) + : []; + + const documentItems = [...filterBySearch(documentPageLinks), ...personalDocumentItems]; + + const templateItems = [...filterBySearch(templatePageLinks), ...personalTemplateItems]; + + const settingsItems = filterBySearch(settingsLinks); + + const allCategories: PromptCategory[] = [ + ...adminSearchCategories, + { + id: 'documents', + label: msg`Documents`, + items: documentItems, + count: documentItems.length, + chipCount: personalDocumentItems.length > 0 ? personalDocumentItems.length : null, + isCapped: personalDocumentItems.length >= PERSONAL_SEARCH_RESULTS_CAP, + isGlobal: false, + }, + { + id: 'templates', + label: msg`Templates`, + items: templateItems, + count: templateItems.length, + chipCount: personalTemplateItems.length > 0 ? personalTemplateItems.length : null, + isCapped: personalTemplateItems.length >= PERSONAL_SEARCH_RESULTS_CAP, + isGlobal: false, + }, + { + id: 'settings', + label: msg`Settings`, + items: settingsItems, + count: settingsItems.length, + chipCount: settingsItems.length, + isCapped: false, + isGlobal: false, + }, + ]; + + return allCategories.filter((category) => category.items.length > 0); + }, [ + teamUrl, + hasValidSearch, + searchDocumentsData, + searchTemplatesData, + adminSearchCategories, + filterBySearch, + goToPage, + ]); + + const effectiveChip = categories.some((category) => category.id === activeChip && category.chipCount !== null) + ? activeChip + : 'all'; + + const visibleCategories = + effectiveChip === 'all' ? categories : categories.filter((category) => category.id === effectiveChip); + + const totalVisibleCount = visibleCategories.reduce((total, category) => total + category.count, 0); + const isVisibleCountCapped = visibleCategories.some((category) => category.isCapped); + + const totalAllCount = categories.reduce((total, category) => total + category.count, 0); + const isAllCountCapped = categories.some((category) => category.isCapped); + + const isAnySearchFetching = isFetchingDocuments || isFetchingTemplates || isFetchingAdminSearch; + + const hasSearchError = isDocumentsSearchError || isTemplatesSearchError || isAdminSearchError; + + const formatChipCount = (count: number, isCapped: boolean) => (isCapped ? `≥${count}` : `${count}`); + + const goToSettings = useCallback(() => push('/settings'), [push]); + const goToDocuments = useCallback(() => { + if (teamUrl) { + push(`/t/${teamUrl}/documents?status=ALL`); + } + }, [push, teamUrl]); + const goToTemplates = useCallback(() => { + if (teamUrl) { + push(`/t/${teamUrl}/templates`); + } + }, [push, teamUrl]); useHotkeys(['ctrl+k', 'meta+k'], toggleOpen, { preventDefault: true }); useHotkeys(SETTINGS_PAGE_SHORTCUT, goToSettings); useHotkeys(DOCUMENTS_PAGE_SHORTCUT, goToDocuments); useHotkeys(TEMPLATES_PAGE_SHORTCUT, goToTemplates); - const handleKeyDown = (e: React.KeyboardEvent) => { - // Escape goes to previous page - // Backspace goes to previous page when search is empty - if (e.key === 'Escape' || (e.key === 'Backspace' && !search)) { - e.preventDefault(); + const handleKeyDown = (event: React.KeyboardEvent) => { + // Escape goes to the previous page, or closes the prompt at the root. + // Backspace goes to the previous page when the search is empty. + if (event.key === 'Escape' || (event.key === 'Backspace' && !search)) { + event.preventDefault(); - if (currentPage === undefined) { + if (activePage === null) { setOpen(false); } - setPages((pages) => pages.slice(0, -1)); + setActivePage(null); } }; + const isSearchLoading = isAnySearchFetching && hasValidSearch; + + const showSearchError = hasValidSearch && !isAnySearchFetching && hasSearchError; + + const showNoResults = hasValidSearch && totalVisibleCount === 0 && !isAnySearchFetching && !hasSearchError; + + const placeholder = match(activePage) + .with('theme', () => msg`Search themes…`) + .with('language', () => msg`Search languages…`) + .otherwise(() => (isUserAdmin ? msg`Search documents, users, organisations…` : msg`Type a command or search...`)); + return ( - - + + + +
+ - - - No results found. - + +
- {templatePageLinks.length > 0 && ( - - - - )} + {activePage === null && ( +
+ setActiveChip('all')} + /> - - - + {categories + .filter((category) => category.chipCount !== null) + .map((category) => ( + setActiveChip(category.id)} + /> + ))} +
+ )} - - addPage('language')}> - {_(msg`Change language`)} - - addPage('theme')}> - {_(msg`Change theme`)} - - - - {(isFetchingDocuments || documentSearchResults.length > 0) && ( - - {isFetchingDocuments ? ( -
- + + {activePage === null && ( + <> + {isSearchLoading && ( + // The single loading state, replacing the results while any + // search is in flight. Mirrors the padding and content + // height of the no results state below so swapping between + // them doesn't change the height of the prompt. +
+
+ +
- ) : ( - )} - - )} - {(isFetchingTemplates || templateSearchResults.length > 0) && ( - - {isFetchingTemplates ? ( -
- + {!isSearchLoading && + visibleCategories.map((category) => ( + + + ))} + + {showSearchError && totalVisibleCount > 0 && ( + // Partial failure: the results from the searches that + // succeeded stay visible, flagged as incomplete. +
+ Some searches failed — results may be incomplete.
- ) : ( - )} - - )} - - )} - {currentPage === 'theme' && } - {currentPage === 'language' && } - - + {showSearchError && totalVisibleCount === 0 && ( + // Total failure: an honest error state instead of a + // misleading "No results", height-matched to it so the + // prompt doesn't jump. +
+
+ Something went wrong +
+
+ We couldn’t complete the search. Try again. +
+
+ )} + + {showNoResults && ( +
+
+ No results for “{trimmedSearch}” +
+
+ Try a different search or switch category. +
+
+ )} + + )} + + {activePage === 'theme' && ( + setActivePage(null)} /> + )} + {activePage === 'language' && ( + setActivePage(null)} /> + )} + + +
+
+ + + + Navigate + + + + Open + + + esc + {activePage === null ? Close : Back} + +
+ + + {hasValidSearch ? ( + {formatChipCount(totalVisibleCount, isVisibleCountCapped)} results + ) : ( + {totalVisibleCount} items + )} + +
+ + +
); -} +}; -const Commands = ({ - push, - pages, +const PromptChip = ({ + label, + count, + isActive, + isGlobal = false, + onSelect, }: { - push: (_path: string) => void; - pages: { label: MessageDescriptor | string; path: string; shortcut?: string; value?: string }[]; + label: string; + count: string; + isActive: boolean; + isGlobal?: boolean; + onSelect: () => void; }) => { const { _ } = useLingui(); - return pages.map((page, idx) => ( - push(page.path)} + return ( + + ); }; -const ThemeCommands = () => { +const PromptKbd = ({ children }: { children: React.ReactNode }) => { + return ( + + {children} + + ); +}; + +const HighlightedText = ({ text, query }: { text: string; query: string }) => { + if (!query) { + return <>{text}; + } + + const index = text.toLowerCase().indexOf(query.toLowerCase()); + + if (index === -1) { + return <>{text}; + } + + return ( + <> + {text.slice(0, index)} + {text.slice(index, index + query.length)} + {text.slice(index + query.length)} + + ); +}; + +const PromptCommandItem = ({ + item, + query, + push, + disabled = false, +}: { + item: PromptItem; + query: string; + push: (_path: string) => void; + disabled?: boolean; +}) => { const { _ } = useLingui(); - const [, setTheme] = useTheme(); + const label = typeof item.label === 'string' ? item.label : _(item.label); - const themes = [ - { label: msg`Light Mode`, theme: Theme.LIGHT, icon: Sun }, - { label: msg`Dark Mode`, theme: Theme.DARK, icon: Moon }, - { label: msg`System Theme`, theme: null, icon: Monitor }, - ] as const; + const onSelect = () => { + if (item.onAction) { + item.onAction(); + return; + } - return themes.map((theme) => ( + if (item.path) { + push(item.path); + } + }; + + const content = ( + <> + + {item.initials ? item.initials : item.icon && } + + + + + + + {item.sublabel && ( + + + + )} + + + {item.shortcut && {item.shortcut}} + + {item.isChecked && } + + + + + + ); + + return ( setTheme(theme.theme)} - className="mx-2 -my-1 rounded-lg first:mt-2 last:mb-2" + value={item.id} + onSelect={onSelect} + disabled={disabled} + className="group items-center gap-3 rounded-lg px-2.5 py-2" > - - {_(theme.label)} + {item.path ? ( + { + // Let the browser handle modified clicks natively, such as opening + // the link in a new tab, without navigating or closing the prompt. + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + event.stopPropagation(); + return; + } + + // Plain clicks bubble to the CommandItem which navigates and + // closes the prompt via onSelect. + event.preventDefault(); + }} + > + {content} + + ) : ( + content + )} - )); + ); }; -const LanguageCommands = () => { +const PromptBackCommand = ({ onBack }: { onBack: () => void }) => { + return ( + undefined} + item={{ + id: 'back', + label: msg`Back`, + icon: ArrowLeftIcon, + onAction: onBack, + }} + /> + ); +}; + +const PromptThemeCommands = ({ + query, + push, + onBack, +}: { + query: string; + push: (_path: string) => void; + onBack: () => void; +}) => { + const { _ } = useLingui(); + + const [theme, setTheme, metadata] = useTheme(); + + const themes = [ + { id: 'theme-light', label: msg`Light Mode`, icon: SunIcon, theme: Theme.LIGHT }, + { id: 'theme-dark', label: msg`Dark Mode`, icon: MoonIcon, theme: Theme.DARK }, + { id: 'theme-system', label: msg`System Theme`, icon: MonitorIcon, theme: null }, + ] as const; + + const visibleThemes = themes.filter((item) => !query || commandScore(_(item.label), query) >= MIN_FUZZY_SCORE); + + const isThemeChecked = (itemTheme: Theme | null) => { + if (itemTheme === null) { + return metadata.definedBy === 'SYSTEM'; + } + + return metadata.definedBy === 'USER' && theme === itemTheme; + }; + + return ( + <> + + + + {visibleThemes.map((item) => ( + setTheme(item.theme), + isChecked: isThemeChecked(item.theme), + }} + /> + ))} + + + ); +}; + +const PromptLanguageCommands = ({ + query, + push, + onBack, +}: { + query: string; + push: (_path: string) => void; + onBack: () => void; +}) => { const { i18n, _ } = useLingui(); const { toast } = useToast(); @@ -383,15 +883,31 @@ const LanguageCommands = () => { setIsLoading(false); }; - return Object.values(SUPPORTED_LANGUAGES).map((language) => ( - setLanguage(language.short)} - className="mx-2 -my-1 rounded-lg first:mt-2 last:mb-2" - > - - {_(language.full)} - - )); + const visibleLanguages = Object.values(SUPPORTED_LANGUAGES).filter( + (language) => !query || commandScore(_(language.full), query) >= MIN_FUZZY_SCORE, + ); + + return ( + <> + + + + {visibleLanguages.map((language) => ( + setLanguage(language.short), + isChecked: i18n.locale === language.short, + }} + /> + ))} + + + ); }; diff --git a/apps/remix/app/components/general/app-command-menu.types.ts b/apps/remix/app/components/general/app-command-menu.types.ts new file mode 100644 index 000000000..e9ab47a69 --- /dev/null +++ b/apps/remix/app/components/general/app-command-menu.types.ts @@ -0,0 +1,36 @@ +import type { MessageDescriptor } from '@lingui/core'; +import type { LucideIcon } from 'lucide-react'; + +export type PromptItem = { + id: string; + label: string | MessageDescriptor; + sublabel?: string; + path?: string; + onAction?: () => void; + icon?: LucideIcon; + initials?: string; + shortcut?: string; + isChecked?: boolean; +}; + +export type PromptCategory = { + id: string; + label: MessageDescriptor; + items: PromptItem[]; + /** + * The number of actual results, excluding utility rows such as the + * "View all results" link. + */ + count: number; + /** + * The count shown on the category chip, or null to not show a chip at all. + * Categories which only contain hardcoded page links have no chip. + */ + chipCount: number | null; + isCapped: boolean; + /** + * Global admin categories are marked with a globe icon to distinguish them + * from the equally named personal categories. + */ + isGlobal: boolean; +}; diff --git a/apps/remix/app/components/general/use-admin-search-categories.ts b/apps/remix/app/components/general/use-admin-search-categories.ts new file mode 100644 index 000000000..fc950b708 --- /dev/null +++ b/apps/remix/app/components/general/use-admin-search-categories.ts @@ -0,0 +1,144 @@ +import { useSession } from '@documenso/lib/client-only/providers/session'; +import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc'; +import { isAdmin } from '@documenso/lib/utils/is-admin'; +import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; +import { trpc as trpcReact } from '@documenso/trpc/react'; +import type { TAdminSearchResultType } from '@documenso/trpc/server/admin-router/admin-search.types'; +import { ADMIN_SEARCH_MAX_QUERY_LENGTH } from '@documenso/trpc/server/admin-router/admin-search.types'; + +import type { MessageDescriptor } from '@lingui/core'; +import { msg } from '@lingui/core/macro'; +import { keepPreviousData } from '@tanstack/react-query'; +import type { LucideIcon } from 'lucide-react'; +import { ArrowRightIcon, Building2Icon, CreditCardIcon, FileTextIcon, UserIcon, UsersIcon } from 'lucide-react'; +import { useMemo } from 'react'; +import type { PromptCategory, PromptItem } from './app-command-menu.types'; + +/** + * The maximum number of results the admin search returns per resource type. + */ +const ADMIN_SEARCH_RESULTS_CAP = 5; + +const ADMIN_GROUP_LABELS: Record = { + document: msg`Documents`, + user: msg`Users`, + organisation: msg`Organisations`, + team: msg`Teams`, + recipient: msg`Recipients`, + subscription: msg`Subscriptions`, +}; + +const ADMIN_GROUP_ICONS: Record = { + document: FileTextIcon, + user: UserIcon, + organisation: Building2Icon, + team: UsersIcon, + recipient: UserIcon, + subscription: CreditCardIcon, +}; + +/** + * Admin list pages which support prefilling their search from the URL, used + * for the "View all results" links on capped groups. Teams, recipients and + * subscriptions have no admin list pages. + */ +const ADMIN_GROUP_LIST_PATHS: Partial string>> = { + document: (query) => `/admin/documents?term=${encodeURIComponent(query)}`, + user: (query) => `/admin/users?search=${encodeURIComponent(query)}`, + organisation: (query) => `/admin/organisations?query=${encodeURIComponent(query)}`, +}; + +export type UseAdminSearchCategoriesOptions = { + /** + * The trimmed, debounced search query. + */ + query: string; + open: boolean; +}; + +/** + * The isolated admin portion of the command prompt: searches every admin + * resource and maps the results to prompt categories marked as global. + * + * Returns no categories and never queries for non admin users. The admin + * search endpoint is additionally guarded server side by the admin procedure. + */ +export const useAdminSearchCategories = ({ query, open }: UseAdminSearchCategoriesOptions) => { + const { user } = useSession(); + + const isUserAdmin = isAdmin(user); + + // Admin searches hit every resource table, so require a longer query unless + // it is a number, which could be a resource ID of any length. Queries over + // the endpoint's length limit are skipped entirely instead of being sent + // and rejected. + const hasValidAdminSearch = + isUserAdmin && query.length <= ADMIN_SEARCH_MAX_QUERY_LENGTH && (query.length > 3 || /^\d+$/.test(query)); + + const { + data: adminSearchData, + isFetching, + isError, + } = trpcReact.admin.search.useQuery( + { + query, + }, + { + enabled: open && hasValidAdminSearch, + placeholderData: keepPreviousData, + // Retyping is the retry in a search-as-you-type flow: fail fast so the + // prompt can surface an honest error state instead of retrying. + retry: false, + ...SKIP_QUERY_BATCH_META, + ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, + }, + ); + + const categories = useMemo((): PromptCategory[] => { + if (!hasValidAdminSearch || !adminSearchData) { + return []; + } + + return adminSearchData.groups.map((group) => { + const isCapped = group.results.length >= ADMIN_SEARCH_RESULTS_CAP; + const buildListPath = ADMIN_GROUP_LIST_PATHS[group.type]; + + const items: PromptItem[] = group.results.map((result) => ({ + id: `admin-${group.type}-${result.value}`, + label: result.label, + sublabel: result.sublabel, + path: result.path, + icon: ADMIN_GROUP_ICONS[group.type], + initials: group.type === 'user' || group.type === 'recipient' ? extractInitials(result.label) : undefined, + })); + + // Capped groups link to the full admin list page with the search + // prefilled so the cap is never a dead end. + if (isCapped && buildListPath) { + items.push({ + id: `admin-${group.type}-view-all`, + label: msg`View all results`, + path: buildListPath(query), + icon: ArrowRightIcon, + }); + } + + return { + id: `admin-${group.type}`, + label: ADMIN_GROUP_LABELS[group.type], + items, + count: group.results.length, + chipCount: group.results.length, + isCapped, + isGlobal: true, + }; + }); + }, [hasValidAdminSearch, adminSearchData, query]); + + return { + isUserAdmin, + categories, + isFetching, + isError, + }; +}; diff --git a/packages/app-tests/e2e/admin/global-search.spec.ts b/packages/app-tests/e2e/admin/global-search.spec.ts new file mode 100644 index 000000000..d8a1b5fbe --- /dev/null +++ b/packages/app-tests/e2e/admin/global-search.spec.ts @@ -0,0 +1,439 @@ +import { seedPendingDocument } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; +import { expect, test } from '@playwright/test'; +import { customAlphabet } from 'nanoid'; + +import { apiSignin } from '../fixtures/authentication'; +import { openCommandMenu } from '../fixtures/command-menu'; + +test.describe.configure({ mode: 'parallel' }); + +const nanoid = customAlphabet('1234567890abcdef', 10); + +const ADMIN_PROMPT_PLACEHOLDER = 'Search documents, users, organisations…'; + +test('[ADMIN][GLOBAL_SEARCH]: numeric query shows verified user result and navigates', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { user: targetUser } = await seedUser(); + + await apiSignin({ page, email: adminUser.email }); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(String(targetUser.id)); + + await expect(page.getByText('Global Users', { exact: true })).toBeVisible(); + + // The category chips include the admin groups with their result counts. + await expect(page.getByRole('button', { name: /Global Users/ })).toBeVisible(); + + const userOption = page.getByRole('option').filter({ hasText: targetUser.email }).first(); + + // Admin results are real links so they support native link behaviour such + // as opening in a new tab. + await expect(userOption.getByRole('link')).toHaveAttribute('href', `/admin/users/${targetUser.id}`); + + await userOption.click(); + + await page.waitForURL(`/admin/users/${targetUser.id}`); +}); + +test('[ADMIN][GLOBAL_SEARCH]: numeric query shows verified team result and navigates', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { team: targetTeam } = await seedUser(); + + await apiSignin({ page, email: adminUser.email }); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(String(targetTeam.id)); + + await expect(page.getByText('Global Teams', { exact: true })).toBeVisible(); + + await page.getByRole('option').filter({ hasText: targetTeam.url }).first().click(); + + await page.waitForURL(`/admin/teams/${targetTeam.id}`); +}); + +test('[ADMIN][GLOBAL_SEARCH]: text query shows document result and navigates', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { user: sender, team } = await seedUser(); + + const document = await seedPendingDocument(sender, team.id, [], { + createDocumentOptions: { title: `admin-ui-search-${nanoid()}` }, + }); + + await apiSignin({ page, email: adminUser.email }); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(document.title); + + await expect(page.getByText('Global Documents', { exact: true })).toBeVisible(); + + await page.getByRole('option').filter({ hasText: document.secondaryId }).first().click(); + + await page.waitForURL(`/admin/documents/${document.id}`); +}); + +test('[ADMIN][GLOBAL_SEARCH]: envelope_ prefixed query resolves exact document', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { user: sender, team } = await seedUser(); + + const document = await seedPendingDocument(sender, team.id, [], { + createDocumentOptions: { title: `admin-ui-search-${nanoid()}` }, + }); + + await apiSignin({ page, email: adminUser.email }); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(document.id); + + await expect(page.getByText('Global Documents', { exact: true })).toBeVisible(); + await expect(page.getByRole('option').filter({ hasText: document.title }).first()).toBeVisible(); +}); + +test('[ADMIN][GLOBAL_SEARCH]: admin search requires more than 3 characters unless numeric', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + const adminSearchRequests: string[] = []; + + page.on('request', (request) => { + if (request.url().includes('admin.search')) { + adminSearchRequests.push(request.url()); + } + }); + + await apiSignin({ page, email: adminUser.email }); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + const input = page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first(); + + // A 3 character non-numeric query must not trigger the admin search. The + // personal document search fires for any non-empty query, so its response + // is the synchronization anchor proving the debounced queries have fired. + const documentSearchResponse = page.waitForResponse((response) => response.url().includes('document.search')); + + await input.fill('abc'); + + await documentSearchResponse; + + await expect(page.getByText(/^Global /)).toHaveCount(0); + expect(adminSearchRequests).toHaveLength(0); + + // A numeric query fires regardless of length. + const adminSearchRequest = page.waitForRequest((request) => request.url().includes('admin.search')); + + await input.fill('7'); + + await adminSearchRequest; +}); + +test('[ADMIN][GLOBAL_SEARCH]: search bar position stays fixed while searching', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { user: targetUser } = await seedUser(); + + await apiSignin({ page, email: adminUser.email }); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + const input = page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first(); + + const initialY = (await input.boundingBox())?.y; + + expect(initialY).toBeGreaterThan(0); + + // The height of the prompt may change as results come and go, but the + // search bar must never move. + await input.fill(String(targetUser.id)); + + await expect(page.getByText('Global Users', { exact: true })).toBeVisible(); + + const resultsY = (await input.boundingBox())?.y; + + expect(resultsY).toBe(initialY); + + // The search bar must not move when there are no results at all. + await input.fill('zzzz-no-such-thing-9x7q'); + + await expect(page.getByText('No results for')).toBeVisible(); + + const emptyY = (await input.boundingBox())?.y; + + expect(emptyY).toBe(initialY); +}); + +test('[ADMIN][GLOBAL_SEARCH]: default view shows the document page links outside a team context', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + await apiSignin({ page, email: adminUser.email }); + + // Admin pages have no current team, the page links must still show. + await page.goto('/admin/stats'); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + await expect(page.getByRole('option').filter({ hasText: 'All documents' })).toBeVisible(); + await expect(page.getByRole('option').filter({ hasText: 'Draft documents' })).toBeVisible(); + await expect(page.getByRole('option').filter({ hasText: 'All templates' })).toBeVisible(); + + // Chips only show for categories with actual results, not for the + // hardcoded page links. + await expect(page.getByRole('button', { name: /^Documents/ })).toHaveCount(0); + await expect(page.getByRole('button', { name: /^Templates/ })).toHaveCount(0); + await expect(page.getByRole('button', { name: /^Settings/ })).toBeVisible(); +}); + +test('[ADMIN][GLOBAL_SEARCH]: theme can be changed from the prompt', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + await apiSignin({ page, email: adminUser.email }); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + await page.getByRole('option').filter({ hasText: 'Change theme' }).first().click(); + + // The sub page has a contextual placeholder and a back option. + await expect(page.getByPlaceholder('Search themes…')).toBeVisible(); + await expect(page.getByRole('option').filter({ hasText: 'Back' }).first()).toBeVisible(); + + await expect(page.getByRole('option').filter({ hasText: 'Dark Mode' })).toBeVisible(); + + await page.getByRole('option').filter({ hasText: 'Dark Mode' }).first().click(); + + await expect(page.locator('html')).toHaveClass(/dark/); + + // The back option returns to the root view. + await page.getByRole('option').filter({ hasText: 'Back' }).first().click(); + + await expect(page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first()).toBeVisible(); +}); + +test('[ADMIN][GLOBAL_SEARCH]: capped admin groups offer a view all link', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + const namePrefix = `viewall-${nanoid()}`; + + // Seed enough users sharing a name prefix to hit the 5 result cap. + for (let i = 0; i < 5; i++) { + await seedUser({ name: `${namePrefix}-${i}` }); + } + + await apiSignin({ page, email: adminUser.email }); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(namePrefix); + + await expect(page.getByText('Global Users', { exact: true })).toBeVisible(); + + const viewAllOption = page.getByRole('option').filter({ hasText: 'View all results' }).first(); + + await expect(viewAllOption.getByRole('link')).toHaveAttribute( + 'href', + `/admin/users?search=${encodeURIComponent(namePrefix)}`, + ); +}); + +test('[ADMIN][GLOBAL_SEARCH]: first result is highlighted after every search', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { user: firstUser } = await seedUser(); + const { user: secondUser } = await seedUser(); + + await apiSignin({ page, email: adminUser.email }); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + const input = page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first(); + + // First search selects the first result. + await input.fill(String(firstUser.id)); + + await expect(page.getByRole('option').filter({ hasText: firstUser.email }).first()).toBeVisible(); + await expect(page.locator('[cmdk-item]').first()).toHaveAttribute('aria-selected', 'true'); + + // A subsequent search with entirely new results must select the first + // result again. + await input.fill(String(secondUser.id)); + + await expect(page.getByRole('option').filter({ hasText: secondUser.email }).first()).toBeVisible(); + await expect(page.locator('[cmdk-item]').first()).toHaveAttribute('aria-selected', 'true'); +}); + +test('[ADMIN][GLOBAL_SEARCH]: static items match fuzzy queries', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + await apiSignin({ page, email: adminUser.email }); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + // "setg" is a non-contiguous abbreviation of "Settings". + await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill('setg'); + + // Wait for the debounced filter to apply first, "Draft documents" can + // never match "setg" under either matching strategy. + await expect(page.getByRole('option').filter({ hasText: 'Draft documents' })).toHaveCount(0); + + await expect(page.getByRole('option').filter({ hasText: 'Settings' }).first()).toBeVisible(); +}); + +test('[ADMIN][GLOBAL_SEARCH]: page scrollbar is hidden while the prompt is open', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + await apiSignin({ page, email: adminUser.email }); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + await expect + .poll(async () => await page.evaluate(() => getComputedStyle(document.documentElement).overflow)) + .toBe('hidden'); + + await page.keyboard.press('Escape'); + + await expect + .poll(async () => await page.evaluate(() => getComputedStyle(document.documentElement).overflow)) + .toBe('visible'); +}); + +test('[ADMIN][GLOBAL_SEARCH]: non-admin gets the prompt without the admin search', async ({ page }) => { + const { user, team } = await seedUser({ isAdmin: false }); + + const document = await seedPendingDocument(user, team.id, []); + + const adminSearchRequests: string[] = []; + + page.on('request', (request) => { + if (request.url().includes('admin.search')) { + adminSearchRequests.push(request.url()); + } + }); + + await apiSignin({ page, email: user.email }); + + // Non-admins get the same prompt with a non-admin placeholder. + await openCommandMenu(page, 'Type a command or search...'); + + await expect(page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER)).toHaveCount(0); + + await page.getByPlaceholder('Type a command or search...').first().fill(document.title); + + // Wait for the regular (non-admin) search to resolve so we know the + // debounced queries have fired. + await expect(page.getByRole('option', { name: document.title })).toBeVisible(); + + await expect(page.getByText(/^Global /)).toHaveCount(0); + expect(adminSearchRequests).toHaveLength(0); +}); + +test('[ADMIN][GLOBAL_SEARCH]: typing on a sub page fires no search requests', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + const searchRequests: string[] = []; + + page.on('request', (request) => { + if (/api\/trpc\/(document|template|admin)\.search/.test(request.url())) { + searchRequests.push(request.url()); + } + }); + + await apiSignin({ page, email: adminUser.email }); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + await page.getByRole('option').filter({ hasText: 'Change theme' }).first().click(); + + const input = page.getByPlaceholder('Search themes…'); + + await expect(input).toBeVisible(); + + // Long enough to pass the admin search threshold if it were enabled. + await input.fill('dark'); + + // The client-side filter applying proves the typing registered. + await expect(page.getByRole('option').filter({ hasText: 'Dark Mode' })).toBeVisible(); + await expect(page.getByRole('option').filter({ hasText: 'Light Mode' })).toHaveCount(0); + + // Wait out the 200ms search debounce with a wide margin before asserting + // that no requests fired: there is no response to anchor on when the + // desired behaviour is "no requests at all". + await page.waitForTimeout(750); + + expect(searchRequests).toHaveLength(0); +}); + +test('[ADMIN][GLOBAL_SEARCH]: failed searches show an error state instead of no results', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + await page.route(/api\/trpc\/(document|template|admin)\.search/, async (route) => { + await route.fulfill({ status: 500, contentType: 'application/json', body: '{}' }); + }); + + await apiSignin({ page, email: adminUser.email }); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill('zzzz-no-such-thing-9x7q'); + + // A failed search must be honest about it, not claim there are no results. + await expect(page.getByText('Something went wrong')).toBeVisible(); + await expect(page.getByText('No results for')).toHaveCount(0); +}); + +test('[ADMIN][GLOBAL_SEARCH]: partial search failure still shows results with a notice', async ({ page }) => { + const { user: adminUser, team } = await seedUser({ isAdmin: true }); + + const document = await seedPendingDocument(adminUser, team.id, [], { + createDocumentOptions: { title: `partial-fail-${nanoid()}` }, + }); + + // Only the admin search fails: the personal searches succeed. + await page.route(/api\/trpc\/admin\.search/, async (route) => { + await route.fulfill({ status: 500, contentType: 'application/json', body: '{}' }); + }); + + await apiSignin({ page, email: adminUser.email }); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(document.title); + + // The successful personal document search must still render its results. + await expect(page.getByRole('option', { name: document.title })).toBeVisible(); + + // The failed admin search must be flagged rather than silently dropped. + await expect(page.getByText('Some searches failed')).toBeVisible(); +}); + +test('[ADMIN][GLOBAL_SEARCH]: over-length query skips the admin search without erroring', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + const adminSearchRequests: string[] = []; + + page.on('request', (request) => { + if (request.url().includes('admin.search')) { + adminSearchRequests.push(request.url()); + } + }); + + await apiSignin({ page, email: adminUser.email }); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + // The admin search endpoint rejects queries longer than 100 characters, so + // the client must not send them. The personal searches accept up to 1024 + // characters and still run, anchoring the debounced query flush. + const documentSearchResponse = page.waitForResponse((response) => response.url().includes('document.search')); + + await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill('a'.repeat(150)); + + await documentSearchResponse; + + // The personal searches ran and found nothing: the honest empty state, with + // no error in sight. + await expect(page.getByText('No results for')).toBeVisible(); + await expect(page.getByText('Something went wrong')).toHaveCount(0); + + expect(adminSearchRequests).toHaveLength(0); +}); diff --git a/packages/app-tests/e2e/api/trpc/admin/admin-search.spec.ts b/packages/app-tests/e2e/api/trpc/admin/admin-search.spec.ts new file mode 100644 index 000000000..62ffc37ea --- /dev/null +++ b/packages/app-tests/e2e/api/trpc/admin/admin-search.spec.ts @@ -0,0 +1,249 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { seedPendingDocument } 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 { customAlphabet } from 'nanoid'; + +import { apiSignin } from '../../../fixtures/authentication'; + +const nanoid = customAlphabet('1234567890abcdef', 10); + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); + +test.describe.configure({ mode: 'parallel' }); + +type AdminSearchGroup = { + type: string; + results: Array<{ label: string; sublabel?: string; path: string; value: string }>; +}; + +const callAdminSearch = async (page: Page, query: string) => { + const inputParam = encodeURIComponent(JSON.stringify({ json: { query } })); + const url = `${WEBAPP_BASE_URL}/api/trpc/admin.search?input=${inputParam}`; + + const res = await page.context().request.get(url); + + return { + res, + groups: res.ok() + ? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + ((await res.json()).result.data.json.groups as AdminSearchGroup[]) + : null, + }; +}; + +const findGroup = (groups: AdminSearchGroup[] | null, type: string) => + (groups ?? []).find((group) => group.type === type); + +// ─── Access control ────────────────────────────────────────────────────────── + +test('[ADMIN][TRPC][SEARCH]: unauthenticated request is rejected with 401', async ({ page }) => { + const { res } = await callAdminSearch(page, 'anything'); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(401); +}); + +test('[ADMIN][TRPC][SEARCH]: non-admin authenticated user is rejected with 401', async ({ page }) => { + const { user: nonAdminUser } = await seedUser({ isAdmin: false }); + + await apiSignin({ page, email: nonAdminUser.email }); + + const { res } = await callAdminSearch(page, 'anything'); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(401); +}); + +// ─── Numeric queries: verified ID lookups ──────────────────────────────────── + +test('[ADMIN][TRPC][SEARCH]: numeric query returns verified user and team rows', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { user: targetUser, team: targetTeam } = await seedUser(); + + await apiSignin({ page, email: adminUser.email }); + + // Search by user ID. + const userSearch = await callAdminSearch(page, String(targetUser.id)); + + expect(userSearch.res.ok()).toBeTruthy(); + + const userGroup = findGroup(userSearch.groups, 'user'); + expect(userGroup).toBeDefined(); + expect(userGroup?.results).toHaveLength(1); + expect(userGroup?.results[0].path).toBe(`/admin/users/${targetUser.id}`); + expect(userGroup?.results[0].sublabel).toContain(targetUser.email); + + // The cmdk `value` contract: value must contain the raw query. + expect(userGroup?.results[0].value).toContain(String(targetUser.id)); + + // Search by team ID. + const teamSearch = await callAdminSearch(page, String(targetTeam.id)); + + expect(teamSearch.res.ok()).toBeTruthy(); + + const teamGroup = findGroup(teamSearch.groups, 'team'); + expect(teamGroup).toBeDefined(); + expect(teamGroup?.results).toHaveLength(1); + expect(teamGroup?.results[0].path).toBe(`/admin/teams/${targetTeam.id}`); + expect(teamGroup?.results[0].label).toBe(targetTeam.name); +}); + +test('[ADMIN][TRPC][SEARCH]: numeric query returns verified document and recipient rows', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { user: sender, team } = await seedUser(); + const { user: recipientUser } = await seedUser(); + + const document = await seedPendingDocument(sender, team.id, [recipientUser]); + const legacyDocumentId = document.secondaryId.replace('document_', ''); + const recipient = document.recipients[0]; + + await apiSignin({ page, email: adminUser.email }); + + // Search by legacy document ID (bare number). + const documentSearch = await callAdminSearch(page, legacyDocumentId); + + expect(documentSearch.res.ok()).toBeTruthy(); + + const documentGroup = findGroup(documentSearch.groups, 'document'); + expect(documentGroup).toBeDefined(); + expect(documentGroup?.results).toHaveLength(1); + expect(documentGroup?.results[0].path).toBe(`/admin/documents/${document.id}`); + expect(documentGroup?.results[0].label).toBe(document.title); + + // Search by recipient ID: links to the parent document. + const recipientSearch = await callAdminSearch(page, String(recipient.id)); + + expect(recipientSearch.res.ok()).toBeTruthy(); + + const recipientGroup = findGroup(recipientSearch.groups, 'recipient'); + expect(recipientGroup).toBeDefined(); + expect(recipientGroup?.results).toHaveLength(1); + expect(recipientGroup?.results[0].path).toBe(`/admin/documents/${document.id}`); + expect(recipientGroup?.results[0].label).toBe(recipient.email); + expect(recipientGroup?.results[0].sublabel).toBe(`#${recipient.id} · ${recipient.name} · ${document.title}`); + + // Search by the full document_ secondary ID: exercises the prefix branch. + const secondaryIdSearch = await callAdminSearch(page, document.secondaryId); + + expect(secondaryIdSearch.res.ok()).toBeTruthy(); + + const secondaryIdGroup = findGroup(secondaryIdSearch.groups, 'document'); + expect(secondaryIdGroup).toBeDefined(); + expect(secondaryIdGroup?.results[0].path).toBe(`/admin/documents/${document.id}`); +}); + +test('[ADMIN][TRPC][SEARCH]: numeric query with no matches returns no groups', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + await apiSignin({ page, email: adminUser.email }); + + const { res, groups } = await callAdminSearch(page, '999999999'); + + expect(res.ok()).toBeTruthy(); + expect(groups).toEqual([]); +}); + +test('[ADMIN][TRPC][SEARCH]: oversized number does not error and falls back to text search', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { user: sender, team } = await seedUser(); + + // 99999999999999 exceeds Int4, so it cannot be an ID lookup: it must be + // treated as text (and must not 500). + const oversizedNumber = '99999999999999'; + + const document = await seedPendingDocument(sender, team.id, [], { + createDocumentOptions: { title: `${oversizedNumber}-${nanoid()}` }, + }); + + await apiSignin({ page, email: adminUser.email }); + + const { res, groups } = await callAdminSearch(page, oversizedNumber); + + expect(res.ok()).toBeTruthy(); + + const documentGroup = findGroup(groups, 'document'); + expect(documentGroup).toBeDefined(); + expect(documentGroup?.results.map((result) => result.path)).toContain(`/admin/documents/${document.id}`); +}); + +// ─── Prefixed ID queries: exact lookups ────────────────────────────────────── + +test('[ADMIN][TRPC][SEARCH]: envelope_ and org_ prefixes resolve exact matches', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { user: sender, organisation, team } = await seedUser(); + + const document = await seedPendingDocument(sender, team.id, []); + + await apiSignin({ page, email: adminUser.email }); + + // envelope_ resolves the document. + const envelopeSearch = await callAdminSearch(page, document.id); + + expect(envelopeSearch.res.ok()).toBeTruthy(); + + const documentGroup = findGroup(envelopeSearch.groups, 'document'); + expect(documentGroup).toBeDefined(); + expect(documentGroup?.results[0].path).toBe(`/admin/documents/${document.id}`); + + // Only the document group is returned for a recognized prefix. + expect(envelopeSearch.groups).toHaveLength(1); + + // org_ resolves the organisation. + const orgSearch = await callAdminSearch(page, organisation.id); + + expect(orgSearch.res.ok()).toBeTruthy(); + + const orgGroup = findGroup(orgSearch.groups, 'organisation'); + expect(orgGroup).toBeDefined(); + expect(orgGroup?.results[0].path).toBe(`/admin/organisations/${organisation.id}`); + expect(orgGroup?.results[0].label).toBe(organisation.name); + + // Only the organisation group is returned for a recognized prefix. + expect(orgSearch.groups).toHaveLength(1); +}); + +// ─── Free text queries ─────────────────────────────────────────────────────── + +test('[ADMIN][TRPC][SEARCH]: text query matches documents by title and users by email', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { user: sender, team } = await seedUser(); + + // A unique title: the default seeded title is shared across the whole suite, + // and global search only returns the newest few matches. + const document = await seedPendingDocument(sender, team.id, [], { + createDocumentOptions: { title: `admin-search-${nanoid()}` }, + }); + + await apiSignin({ page, email: adminUser.email }); + + // Search by document title. + const titleSearch = await callAdminSearch(page, document.title); + + expect(titleSearch.res.ok()).toBeTruthy(); + + const documentGroup = findGroup(titleSearch.groups, 'document'); + expect(documentGroup).toBeDefined(); + expect(documentGroup?.results.map((result) => result.path)).toContain(`/admin/documents/${document.id}`); + + // Search by user email (emails are unique nanoid-based, so this is specific). + const emailSearch = await callAdminSearch(page, sender.email); + + expect(emailSearch.res.ok()).toBeTruthy(); + + const userGroup = findGroup(emailSearch.groups, 'user'); + expect(userGroup).toBeDefined(); + expect(userGroup?.results[0].path).toBe(`/admin/users/${sender.id}`); +}); + +test('[ADMIN][TRPC][SEARCH]: gibberish query returns no groups', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + await apiSignin({ page, email: adminUser.email }); + + const { res, groups } = await callAdminSearch(page, 'zzzz-no-such-thing-9x7q'); + + expect(res.ok()).toBeTruthy(); + expect(groups).toEqual([]); +}); diff --git a/packages/app-tests/e2e/command-menu/document-search.spec.ts b/packages/app-tests/e2e/command-menu/document-search.spec.ts index a30823502..e4c422d58 100644 --- a/packages/app-tests/e2e/command-menu/document-search.spec.ts +++ b/packages/app-tests/e2e/command-menu/document-search.spec.ts @@ -3,6 +3,9 @@ import { seedUser } from '@documenso/prisma/seed/users'; import { expect, test } from '@playwright/test'; import { apiSignin } from '../fixtures/authentication'; +import { openCommandMenu } from '../fixtures/command-menu'; + +const COMMAND_MENU_PLACEHOLDER = 'Type a command or search...'; test('[COMMAND_MENU]: should see sent documents', async ({ page }) => { const { user, team } = await seedUser(); @@ -14,9 +17,9 @@ test('[COMMAND_MENU]: should see sent documents', async ({ page }) => { email: user.email, }); - await page.keyboard.press('Meta+K'); + await openCommandMenu(page, COMMAND_MENU_PLACEHOLDER); - await page.getByPlaceholder('Type a command or search...').first().fill(document.title); + await page.getByPlaceholder(COMMAND_MENU_PLACEHOLDER).first().fill(document.title); await expect(page.getByRole('option', { name: document.title })).toBeVisible(); }); @@ -30,9 +33,9 @@ test('[COMMAND_MENU]: should see received documents', async ({ page }) => { email: recipient.email, }); - await page.keyboard.press('Meta+K'); + await openCommandMenu(page, COMMAND_MENU_PLACEHOLDER); - await page.getByPlaceholder('Type a command or search...').first().fill(document.title); + await page.getByPlaceholder(COMMAND_MENU_PLACEHOLDER).first().fill(document.title); await expect(page.getByRole('option', { name: document.title })).toBeVisible(); }); @@ -46,8 +49,8 @@ test('[COMMAND_MENU]: should be able to search by recipient', async ({ page }) = email: user.email, }); - await page.keyboard.press('Meta+K'); + await openCommandMenu(page, COMMAND_MENU_PLACEHOLDER); - await page.getByPlaceholder('Type a command or search...').first().fill(recipient.email); + await page.getByPlaceholder(COMMAND_MENU_PLACEHOLDER).first().fill(recipient.email); await expect(page.getByRole('option', { name: document.title })).toBeVisible(); }); diff --git a/packages/app-tests/e2e/fixtures/command-menu.ts b/packages/app-tests/e2e/fixtures/command-menu.ts new file mode 100644 index 000000000..d57d75acc --- /dev/null +++ b/packages/app-tests/e2e/fixtures/command-menu.ts @@ -0,0 +1,18 @@ +import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; + +/** + * Opens the app command menu via the keyboard shortcut. + * + * Retries the shortcut until the menu appears since the keypress is a no-op + * when it happens before the page has hydrated. + * + * @param placeholder The search input placeholder to wait for, which differs + * between admin and non-admin users. + */ +export const openCommandMenu = async (page: Page, placeholder: string) => { + await expect(async () => { + await page.keyboard.press('Meta+K'); + await expect(page.getByPlaceholder(placeholder).first()).toBeVisible({ timeout: 1_000 }); + }).toPass({ timeout: 15_000 }); +}; diff --git a/packages/lib/server-only/admin/admin-global-search.ts b/packages/lib/server-only/admin/admin-global-search.ts new file mode 100644 index 000000000..3a6e5fd40 --- /dev/null +++ b/packages/lib/server-only/admin/admin-global-search.ts @@ -0,0 +1,372 @@ +import { prisma } from '@documenso/prisma'; +import { EnvelopeType } from '@prisma/client'; + +export const ADMIN_SEARCH_RESULTS_PER_TYPE = 5; + +const MAX_POSTGRES_INT = 2147483647; + +const GROUP_ORDER = ['document', 'user', 'organisation', 'team', 'recipient', 'subscription'] as const; + +export type AdminGlobalSearchResultType = (typeof GROUP_ORDER)[number]; + +export type AdminGlobalSearchResult = { + label: string; + sublabel?: string; + path: string; + value: string; +}; + +export type AdminGlobalSearchGroup = { + type: AdminGlobalSearchResultType; + results: AdminGlobalSearchResult[]; +}; + +export type AdminGlobalSearchOptions = { + query: string; +}; + +type PartialResults = Partial>; + +export const adminGlobalSearch = async ({ query }: AdminGlobalSearchOptions): Promise => { + const trimmedQuery = query.trim(); + + if (trimmedQuery.length === 0) { + return []; + } + + const resultsByType = await resolveSearch(trimmedQuery); + + return GROUP_ORDER.map((type) => ({ + type, + results: (resultsByType[type] ?? []).map((result) => ({ + ...result, + // Append the raw query so cmdk's client-side filter never hides + // server-verified results. + value: `${result.value} ${trimmedQuery}`, + })), + })).filter((group) => group.results.length > 0); +}; + +const resolveSearch = async (query: string): Promise => { + // Recognized ID prefixes resolve to a single exact lookup. + if (query.startsWith('envelope_')) { + return { document: await findDocumentsByExactId({ id: query }) }; + } + + if (query.startsWith('document_')) { + return { document: await findDocumentsByExactId({ secondaryId: query }) }; + } + + if (query.startsWith('org_')) { + return { organisation: await findOrganisationsByIdOrUrl(query) }; + } + + // Bare numbers are treated as verified ID lookups only. Oversized numbers + // fall through to text search. + const numericId = Number(query); + + if (/^\d+$/.test(query) && numericId <= MAX_POSTGRES_INT) { + const [document, user, team, recipient, subscription] = await Promise.all([ + findDocumentsByExactId({ secondaryId: `document_${numericId}` }), + findUsersById(numericId), + findTeamsById(numericId), + findRecipientsById(numericId), + findSubscriptionsById(numericId), + ]); + + return { document, user, team, recipient, subscription }; + } + + // Free text searches all resource types in parallel. + const [document, user, organisation, team, recipient, subscription] = await Promise.all([ + findDocumentsByText(query), + findUsersByText(query), + findOrganisationsByText(query), + findTeamsByText(query), + findRecipientsByText(query), + findSubscriptionsByText(query), + ]); + + return { + document, + user, + organisation, + team, + recipient, + subscription, + }; +}; + +const joinSublabel = (parts: Array) => + parts.filter((part) => part && part.length > 0).join(' · ') || undefined; + +// ─── Documents ──────────────────────────────────────────────────────────────── + +const documentSelect = { + id: true, + title: true, + secondaryId: true, + user: { select: { email: true } }, +} as const; + +type DocumentRow = { + id: string; + title: string; + secondaryId: string; + user: { email: string }; +}; + +const mapDocument = (envelope: DocumentRow): AdminGlobalSearchResult => ({ + label: envelope.title, + sublabel: joinSublabel([envelope.secondaryId, envelope.user.email]), + path: `/admin/documents/${envelope.id}`, + value: `document ${envelope.id} ${envelope.secondaryId} ${envelope.title} ${envelope.user.email}`, +}); + +const findDocumentsByExactId = async (where: { id: string } | { secondaryId: string }) => { + const envelope = await prisma.envelope.findFirst({ + where: { ...where, type: EnvelopeType.DOCUMENT }, + select: documentSelect, + }); + + return envelope ? [mapDocument(envelope)] : []; +}; + +const findDocumentsByText = async (query: string) => { + const envelopes = await prisma.envelope.findMany({ + where: { + type: EnvelopeType.DOCUMENT, + title: { contains: query, mode: 'insensitive' }, + }, + orderBy: { createdAt: 'desc' }, + take: ADMIN_SEARCH_RESULTS_PER_TYPE, + select: documentSelect, + }); + + return envelopes.map(mapDocument); +}; + +// ─── Users ──────────────────────────────────────────────────────────────────── + +const userSelect = { + id: true, + name: true, + email: true, +} as const; + +type UserRow = { id: number; name: string | null; email: string }; + +const mapUser = (user: UserRow): AdminGlobalSearchResult => ({ + label: user.name || user.email, + sublabel: joinSublabel([`#${user.id}`, user.email]), + path: `/admin/users/${user.id}`, + value: `user ${user.id} ${user.name ?? ''} ${user.email}`, +}); + +const findUsersById = async (id: number) => { + const user = await prisma.user.findFirst({ + where: { id }, + select: userSelect, + }); + + return user ? [mapUser(user)] : []; +}; + +const findUsersByText = async (query: string) => { + const users = await prisma.user.findMany({ + where: { + OR: [{ name: { contains: query, mode: 'insensitive' } }, { email: { contains: query, mode: 'insensitive' } }], + }, + orderBy: { id: 'desc' }, + take: ADMIN_SEARCH_RESULTS_PER_TYPE, + select: userSelect, + }); + + return users.map(mapUser); +}; + +// ─── Organisations ──────────────────────────────────────────────────────────── + +const organisationSelect = { + id: true, + name: true, + owner: { select: { email: true } }, +} as const; + +type OrganisationRow = { id: string; name: string; owner: { email: string } }; + +const mapOrganisation = (organisation: OrganisationRow): AdminGlobalSearchResult => ({ + label: organisation.name, + sublabel: joinSublabel([organisation.id, organisation.owner.email]), + path: `/admin/organisations/${organisation.id}`, + value: `organisation ${organisation.id} ${organisation.name} ${organisation.owner.email}`, +}); + +const findOrganisationsByIdOrUrl = async (query: string) => { + const organisations = await prisma.organisation.findMany({ + where: { + OR: [{ id: query }, { url: query }], + }, + take: ADMIN_SEARCH_RESULTS_PER_TYPE, + select: organisationSelect, + }); + + return organisations.map(mapOrganisation); +}; + +const findOrganisationsByText = async (query: string) => { + const organisations = await prisma.organisation.findMany({ + where: { + OR: [ + { name: { contains: query, mode: 'insensitive' } }, + { url: { contains: query, mode: 'insensitive' } }, + { customerId: { contains: query, mode: 'insensitive' } }, + { owner: { email: { contains: query, mode: 'insensitive' } } }, + ], + }, + orderBy: { createdAt: 'desc' }, + take: ADMIN_SEARCH_RESULTS_PER_TYPE, + select: organisationSelect, + }); + + return organisations.map(mapOrganisation); +}; + +// ─── Teams ──────────────────────────────────────────────────────────────────── + +const teamSelect = { + id: true, + name: true, + url: true, + organisation: { select: { name: true } }, +} as const; + +type TeamRow = { id: number; name: string; url: string; organisation: { name: string } }; + +const mapTeam = (team: TeamRow): AdminGlobalSearchResult => ({ + label: team.name, + sublabel: joinSublabel([`#${team.id}`, `/${team.url}`, team.organisation.name]), + path: `/admin/teams/${team.id}`, + value: `team ${team.id} ${team.name} ${team.url} ${team.organisation.name}`, +}); + +const findTeamsById = async (id: number) => { + const team = await prisma.team.findFirst({ + where: { id }, + select: teamSelect, + }); + + return team ? [mapTeam(team)] : []; +}; + +const findTeamsByText = async (query: string) => { + const teams = await prisma.team.findMany({ + where: { + OR: [{ name: { contains: query, mode: 'insensitive' } }, { url: { contains: query, mode: 'insensitive' } }], + }, + orderBy: { createdAt: 'desc' }, + take: ADMIN_SEARCH_RESULTS_PER_TYPE, + select: teamSelect, + }); + + return teams.map(mapTeam); +}; + +// ─── Recipients ─────────────────────────────────────────────────────────────── + +const recipientSelect = { + id: true, + name: true, + email: true, + envelope: { select: { id: true, title: true } }, +} as const; + +type RecipientRow = { + id: number; + name: string; + email: string; + envelope: { id: string; title: string }; +}; + +const mapRecipient = (recipient: RecipientRow): AdminGlobalSearchResult => ({ + label: recipient.email, + sublabel: joinSublabel([`#${recipient.id}`, recipient.name, recipient.envelope.title]), + path: `/admin/documents/${recipient.envelope.id}`, + value: `recipient ${recipient.id} ${recipient.name} ${recipient.email} ${recipient.envelope.title}`, +}); + +const findRecipientsById = async (id: number) => { + const recipient = await prisma.recipient.findFirst({ + where: { + id, + envelope: { type: EnvelopeType.DOCUMENT }, + }, + select: recipientSelect, + }); + + return recipient ? [mapRecipient(recipient)] : []; +}; + +const findRecipientsByText = async (query: string) => { + const recipients = await prisma.recipient.findMany({ + where: { + envelope: { type: EnvelopeType.DOCUMENT }, + OR: [{ email: { contains: query, mode: 'insensitive' } }, { name: { contains: query, mode: 'insensitive' } }], + }, + orderBy: { id: 'desc' }, + take: ADMIN_SEARCH_RESULTS_PER_TYPE, + select: recipientSelect, + }); + + return recipients.map(mapRecipient); +}; + +// ─── Subscriptions ──────────────────────────────────────────────────────────── + +const subscriptionSelect = { + id: true, + status: true, + planId: true, + customerId: true, + organisationId: true, +} as const; + +type SubscriptionRow = { + id: number; + status: string; + planId: string; + customerId: string; + organisationId: string; +}; + +const mapSubscription = (subscription: SubscriptionRow): AdminGlobalSearchResult => ({ + label: `Subscription #${subscription.id}`, + sublabel: joinSublabel([subscription.status, subscription.planId]), + path: `/admin/organisations/${subscription.organisationId}`, + value: `subscription ${subscription.id} ${subscription.planId} ${subscription.customerId}`, +}); + +const findSubscriptionsById = async (id: number) => { + const subscription = await prisma.subscription.findFirst({ + where: { id }, + select: subscriptionSelect, + }); + + return subscription ? [mapSubscription(subscription)] : []; +}; + +const findSubscriptionsByText = async (query: string) => { + const subscriptions = await prisma.subscription.findMany({ + where: { + OR: [ + { planId: { contains: query, mode: 'insensitive' } }, + { customerId: { contains: query, mode: 'insensitive' } }, + ], + }, + orderBy: { createdAt: 'desc' }, + take: ADMIN_SEARCH_RESULTS_PER_TYPE, + select: subscriptionSelect, + }); + + return subscriptions.map(mapSubscription); +}; diff --git a/packages/trpc/server/admin-router/admin-search.ts b/packages/trpc/server/admin-router/admin-search.ts new file mode 100644 index 000000000..fe69e125a --- /dev/null +++ b/packages/trpc/server/admin-router/admin-search.ts @@ -0,0 +1,15 @@ +import { adminGlobalSearch } from '@documenso/lib/server-only/admin/admin-global-search'; + +import { adminProcedure } from '../trpc'; +import { ZAdminSearchRequestSchema, ZAdminSearchResponseSchema } from './admin-search.types'; + +export const adminSearchRoute = adminProcedure + .input(ZAdminSearchRequestSchema) + .output(ZAdminSearchResponseSchema) + .query(async ({ input }) => { + const { query } = input; + + const groups = await adminGlobalSearch({ query }); + + return { groups }; + }); diff --git a/packages/trpc/server/admin-router/admin-search.types.ts b/packages/trpc/server/admin-router/admin-search.types.ts new file mode 100644 index 000000000..32cc101ba --- /dev/null +++ b/packages/trpc/server/admin-router/admin-search.types.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; + +export const ZAdminSearchResultTypeSchema = z.enum([ + 'document', + 'user', + 'organisation', + 'team', + 'recipient', + 'subscription', +]); + +export const ZAdminSearchResultSchema = z.object({ + label: z.string(), + sublabel: z.string().optional(), + path: z.string(), + value: z.string(), +}); + +export const ADMIN_SEARCH_MAX_QUERY_LENGTH = 100; + +export const ZAdminSearchRequestSchema = z.object({ + query: z.string().trim().min(1).max(ADMIN_SEARCH_MAX_QUERY_LENGTH), +}); + +export const ZAdminSearchResponseSchema = z.object({ + groups: z.array( + z.object({ + type: ZAdminSearchResultTypeSchema, + results: ZAdminSearchResultSchema.array(), + }), + ), +}); + +export type TAdminSearchResultType = z.infer; +export type TAdminSearchResult = z.infer; +export type TAdminSearchRequest = z.infer; +export type TAdminSearchResponse = z.infer; diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index 7aec1968f..45db5ccbc 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -1,4 +1,5 @@ import { router } from '../trpc'; +import { adminSearchRoute } from './admin-search'; import { createAdminOrganisationRoute } from './create-admin-organisation'; import { createStripeCustomerRoute } from './create-stripe-customer'; import { createSubscriptionClaimRoute } from './create-subscription-claim'; @@ -118,5 +119,6 @@ export const adminRouter = router({ teamMember: { delete: deleteAdminTeamMemberRoute, }, + search: adminSearchRoute, updateSiteSetting: updateSiteSettingRoute, }); From e4897fa6864ad842d71871dad169dbd5979c0252 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Fri, 24 Jul 2026 09:29:46 +0300 Subject: [PATCH 09/27] fix: sort CC recipients last (#2930) --- .../envelope-editor-recipient-form.tsx | 115 +++++---- package-lock.json | 24 -- .../e2e/api/v1/document-sending.spec.ts | 202 +++++++++++++++- .../next-recipient-dictation.spec.ts | 227 +++++++++++++++++- .../document-flow/stepper-component.spec.ts | 205 +++++++++++++++- .../envelope-recipient-cc-order.spec.ts | 155 ++++++++++++ .../e2e/teams/default-recipients.spec.ts | 2 +- .../hooks/use-editor-recipients.ts | 7 +- .../get-envelope-for-recipient-signing.ts | 7 +- .../recipient/get-is-recipient-turn.ts | 7 +- .../recipient/get-next-pending-recipient.ts | 7 +- packages/lib/utils/recipients.test.ts | 71 ++++++ packages/lib/utils/recipients.ts | 56 ++++- .../primitives/document-flow/add-signers.tsx | 138 ++++++----- 14 files changed, 1081 insertions(+), 142 deletions(-) create mode 100644 packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-cc-order.spec.ts create mode 100644 packages/lib/utils/recipients.test.ts 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) && ( Date: Mon, 27 Jul 2026 02:18:03 +0000 Subject: [PATCH 10/27] feat: add copy button for license key in admin panel (#3123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Adds a copy button next to the show/hide toggle for the license key in the admin panel license card (Admin Panel → Stats). - Ghost button matching the existing eye-toggle styling (`h-6 w-6`, `CopyIcon`) - Uses the standard `useCopyToClipboard` + toast pattern (same as `template-direct-link-badge.tsx`) - Copies the key regardless of masked/visible state New translation strings will be picked up by the next `chore: extract translations` run. ## Before / After ![Before and after: copy button added next to the license key show/hide toggle](https://artifacts.duncan.land/documenso-pr3123-license-copy) > Screenshots taken against a locally mocked ACTIVE license (the mock is not part of this PR). ## Testing - `npx tsc --noEmit -p apps/remix` clean - `biome check` clean - Smoke-tested in browser: clicking the button fires the "Copied to clipboard" toast (visible in the screenshot above) --- .../components/general/admin-license-card.tsx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/remix/app/components/general/admin-license-card.tsx b/apps/remix/app/components/general/admin-license-card.tsx index c3a3428df..3e8c39192 100644 --- a/apps/remix/app/components/general/admin-license-card.tsx +++ b/apps/remix/app/components/general/admin-license-card.tsx @@ -1,3 +1,4 @@ +import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; import type { TCachedLicense } from '@documenso/lib/types/license'; import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription'; import { trpc } from '@documenso/trpc/react'; @@ -9,6 +10,7 @@ import { Trans, useLingui } from '@lingui/react/macro'; import { ArrowRightIcon, CheckCircle2Icon, + CopyIcon, EyeIcon, EyeOffIcon, KeyRoundIcon, @@ -29,6 +31,8 @@ type AdminLicenseCardProps = { export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => { const { t, i18n } = useLingui(); + const { toast } = useToast(); + const [, copy] = useCopyToClipboard(); const [isLicenseKeyVisible, setIsLicenseKeyVisible] = useState(false); const { license } = licenseData || {}; @@ -147,6 +151,24 @@ export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => { > {isLicenseKeyVisible ? : } + +
From 6ec67d1c4db67812c737a30e40f8e0bd6086f920 Mon Sep 17 00:00:00 2001 From: Ephraim Duncan <55143799+ephraimduncan@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:41:45 +0000 Subject: [PATCH 11/27] feat: rejected and expired recipient filters (#2889) --- ...wave-rejected-expired-recipient-filters.md | 146 ++++++++ .../general/document/document-status.tsx | 8 +- .../tables/documents-table-empty-state.tsx | 12 +- .../t.$teamUrl+/documents._index.tsx | 3 + .../e2e/api/v2/find-documents.spec.ts | 312 +++++++++++++++++- .../e2e/api/v2/find-envelopes.spec.ts | 117 +++++++ .../e2e/documents/find-documents.spec.ts | 138 +++++++- .../server-only/admin/get-documents-stats.ts | 2 +- .../server-only/document/find-documents.ts | 33 ++ .../lib/server-only/document/get-stats.ts | 19 +- .../server-only/envelope/find-envelopes.ts | 12 + .../lib/server-only/envelope/query-helpers.ts | 27 ++ .../prisma/types/extended-document-status.ts | 1 + .../find-documents-internal.ts | 2 + .../find-documents-internal.types.ts | 1 + .../server/document-router/find-documents.ts | 14 +- .../document-router/find-documents.types.ts | 5 + .../redistribute-document.types.ts | 2 +- .../server/envelope-router/find-envelopes.ts | 16 +- .../envelope-router/find-envelopes.types.ts | 5 + .../redistribute-envelope.types.ts | 2 +- 21 files changed, 867 insertions(+), 10 deletions(-) create mode 100644 .agents/plans/wild-indigo-wave-rejected-expired-recipient-filters.md create mode 100644 packages/lib/server-only/envelope/query-helpers.ts diff --git a/.agents/plans/wild-indigo-wave-rejected-expired-recipient-filters.md b/.agents/plans/wild-indigo-wave-rejected-expired-recipient-filters.md new file mode 100644 index 000000000..b8e9fb39c --- /dev/null +++ b/.agents/plans/wild-indigo-wave-rejected-expired-recipient-filters.md @@ -0,0 +1,146 @@ +--- +date: 2026-05-28 +title: Rejected Expired Recipient Filters +--- + +## Context + +Customers need to find (a) envelopes/documents in the `REJECTED` state and (b) envelopes +with at least one recipient whose signing link has **expired**. Today the UI only exposes +`INBOX / PENDING / COMPLETED / DRAFT / ALL` tabs, and the public API has no way to filter by +expired recipient links — forcing a fetch-all-`PENDING`-then-inspect-each-recipient workaround. + +Two key facts from exploration shaped this plan: + +- **`REJECTED` is already fully wired in the backend** — the where-clause (`find-documents.ts`), + stats counts (`get-stats.ts`), tRPC response schema, `ExtendedDocumentStatus` enum, and the + `FRIENDLY_STATUS_MAP` display all handle it. It is simply absent from the UI tab array. +- **Renewing expired links already works.** `resendDocument` refreshes `expiresAt` and clears + `expirationNotifiedAt` for unsigned, non-CC recipients (`resend-document.ts:98-121`), exposed + publicly via `POST /api/v2/document/redistribute` and `/api/v2/envelope/redistribute` and via the + resend/redistribute UI dialogs. No new renew mechanism is needed — only documentation/wording. + +Expiration is a per-recipient condition (not an envelope status). The approved design models it +in the UI as an `EXPIRED` **pseudo-status tab** (reusing the existing tab machinery, mirroring how +`REJECTED` works) and in the public API as an orthogonal boolean `hasExpiredRecipients`. Both share +one EXISTS predicate. + +Definition of "expired recipient" (matches `isRecipientExpired`, `packages/lib/utils/recipients.ts:118`): +a `Recipient` with `expiresAt IS NOT NULL AND expiresAt <= now() AND signingStatus = NOT_SIGNED AND role != CC`. + +## Approach + +### A. Shared EXISTS predicate (reused 4x, justified) +Add a local `hasExpiredRecipient(eb)` helper — modeled on the existing per-file `recipientExists` / +`senderEmailIs` helpers — to `find-documents.ts`, `get-stats.ts`, and `find-envelopes.ts`. It is the +single source of truth for the expired condition above (using `new Date()` for `now`, matching the +`period` filter's `.toJSDate()` style). + +### B. REJECTED tab (UI only — backend already done) +- `apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx`: add + `ExtendedDocumentStatus.REJECTED` to the tab array (lines 149-155). Count badge, highlight, and + `?status=REJECTED` filtering already work via existing machinery. + +### C. EXPIRED pseudo-status (UI + internal stats) +1. `packages/prisma/types/extended-document-status.ts`: add `EXPIRED: 'EXPIRED'`. Internal-only — + the public `DocumentStatus` enum is unaffected. This intentionally surfaces TS errors at the three + exhaustive/`Record` sites below, forcing them to be handled. +2. `packages/lib/server-only/document/find-documents.ts`: + - Add `.with(ExtendedDocumentStatus.EXPIRED, ...)` to **both** `applyPersonalFilters` and + `applyTeamFilters`, mirroring the `COMPLETED` branch's access control (deleted + visibility + + owner/recipient access) with `hasExpiredRecipient(eb)` AND-ed in. Do **not** constrain + `Envelope.status` — the EXISTS already restricts to unsigned recipients. +3. `packages/lib/server-only/document/get-stats.ts`: + - Add an `expiredQuery` mirroring `pendingQuery`'s access control + `hasExpiredRecipient(eb)`. + - Add it to the `Promise.all`, add `[ExtendedDocumentStatus.EXPIRED]: expired` to the `stats` + record. **Do not** add `expired` to the `all` sum (it overlaps `PENDING`). +4. `packages/trpc/server/document-router/find-documents-internal.types.ts`: add + `[ExtendedDocumentStatus.EXPIRED]: z.number()` to the `stats` response object. (`status` already + accepts the extended enum via `z.nativeEnum(ExtendedDocumentStatus)`.) +5. `apps/remix/app/components/general/document/document-status.tsx`: add an `EXPIRED` entry to + `FRIENDLY_STATUS_MAP` — `label: msg` Expired, an icon (e.g. lucide `TimerOff`, matching the + `/sign/$token/expired` page), and a distinct color (e.g. `text-orange-500`) to differentiate from + `REJECTED` (red). +6. `documents._index.tsx`: add `[ExtendedDocumentStatus.EXPIRED]: 0` to the `stats` `useState` + initializer and `ExtendedDocumentStatus.EXPIRED` to the tab array. Final order: + `INBOX, PENDING, COMPLETED, DRAFT, REJECTED, EXPIRED, ALL`. +7. (Optional, recommended) `apps/remix/app/components/tables/documents-table-empty-state.tsx`: add + tailored `EXPIRED` and `REJECTED` empty-state copy (currently both fall through to `.otherwise()`). + +### D. Public API boolean `hasExpiredRecipients` (document + envelope, v2) +1. `packages/lib/server-only/document/find-documents.ts`: add `hasExpiredRecipients?: boolean` to + `FindDocumentsOptions`; when true, apply `.where((eb) => hasExpiredRecipient(eb))` inside + `buildBaseQuery` (orthogonal/additive to any `status`). +2. `packages/trpc/server/document-router/find-documents.types.ts`: add a query-safe boolean + `hasExpiredRecipients` to `ZFindDocumentsRequestSchema` with a `.describe(...)`. Mirror the + existing boolean-query-param handling in `find-document-audit-logs.types.ts` + (`filterForRecentActivity`) — avoid raw `z.coerce.boolean()` (the "false" -> true footgun); use a + string transform if needed. Pass it through in `find-documents.ts` (public handler). +3. `packages/lib/server-only/envelope/find-envelopes.ts`: add `hasExpiredRecipients?: boolean` to + `FindEnvelopesOptions` + the `hasExpiredRecipient(eb)` helper + the additive `.where`. +4. `packages/trpc/server/envelope-router/find-envelopes.types.ts`: add the same param to + `ZFindEnvelopesRequestSchema`; pass it through in the envelope-router find handler. + The param auto-appears in the generated `/api/v2/openapi.json`. + +Note: REST v1 `GET /api/v1/documents` is deprecated and lacks status filtering — left unchanged. +`REJECTED` is already a valid public `status` value (`DocumentStatus.REJECTED`), so no API change is +needed for rejected filtering. + +### E. Renew expired links — documentation only +No functional change. Document that resending renews expired links: +- Update the `.description` in `packages/trpc/server/document-router/redistribute-document.types.ts` + and `packages/trpc/server/envelope-router/redistribute-envelope.types.ts` to state that + redistributing refreshes the signing-link expiration for unsigned recipients. +- Optionally adjust resend/redistribute dialog copy + (`apps/remix/app/components/dialogs/document-resend-dialog.tsx`, + `envelope-redistribute-dialog.tsx`) to mention it renews expired links. + +## Files To Modify (summary) + +| Area | File | +|------|------| +| Enum | `packages/prisma/types/extended-document-status.ts` | +| Where-clause + API option | `packages/lib/server-only/document/find-documents.ts` | +| Stats counts | `packages/lib/server-only/document/get-stats.ts` | +| Envelope find (API) | `packages/lib/server-only/envelope/find-envelopes.ts` | +| Internal tRPC stats schema | `packages/trpc/server/document-router/find-documents-internal.types.ts` | +| Public doc API schema + handler | `packages/trpc/server/document-router/find-documents.types.ts`, `find-documents.ts` | +| Public envelope API schema + handler | `packages/trpc/server/envelope-router/find-envelopes.types.ts`, `find-envelopes.ts` | +| Status display | `apps/remix/app/components/general/document/document-status.tsx` | +| Tabs + stats init | `apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx` | +| Empty state (optional) | `apps/remix/app/components/tables/documents-table-empty-state.tsx` | +| Renew docs | `redistribute-document.types.ts`, `redistribute-envelope.types.ts` (+ resend dialogs, optional) | + +## Reused Utilities / Patterns +- `recipientExists` / `senderEmailIs` (per-file Kysely EXISTS helpers) — the template for the new + `hasExpiredRecipient` helper. +- `REJECTED` branches in `find-documents.ts` (lines 279, 416) and `rejectedQuery` in `get-stats.ts` + (line 227) — the template for the `EXPIRED` branches / `expiredQuery`. +- `isRecipientExpired` (`packages/lib/utils/recipients.ts:118`) — defines the `expiresAt <= now` + semantics to match. +- Existing tab machinery in `documents._index.tsx` (`getTabHref`, count badge, personal-org `.filter`) + — works unchanged for the new tabs. +- `resendDocument` / `trpc.document.redistribute` / `trpc.envelope.redistribute` — existing renew path. + +## Verification +1. **Typecheck** (the enum change forces all exhaustive/Record sites): `npm run typecheck -w @documenso/remix`. +2. **Seed + UI** (dev server already running): seed a team via `seedTeam`, send a document, then: + - Reject one as a recipient -> it appears under the new **Rejected** tab with a count. + - Force expiry (set a recipient `expiresAt` in the past, e.g. via Prisma Studio or a short + `envelopeExpirationPeriod`) -> the doc appears under the new **Expired** tab with a count, and the + count excludes signed/CC recipients. +3. **Public API**: `GET /api/v2/document?hasExpiredRecipients=true` and + `GET /api/v2/envelope?hasExpiredRecipients=true` (Bearer API token) return only envelopes with >=1 + expired unsigned recipient; confirm `GET /api/v2/document?status=REJECTED` works. Verify the param + appears in `/api/v2/openapi.json`. +4. **Renew**: on an expired doc, run resend/redistribute (UI dialog or + `POST /api/v2/document/redistribute`) -> recipient `expiresAt` is refreshed, the doc leaves the + Expired tab, and the signing link no longer redirects to `/sign/$token/expired`. +5. **E2E** (optional): extend `packages/app-tests/e2e/envelopes/envelope-expiration-send.spec.ts` + with an Expired-tab assertion. +6. Do **not** modify/commit `packages/lib/translations/*.po`; run `npm run translate` only if needed + for new `msg`/`Trans` strings, and keep generated `.po` files out of the branch. + +## Open Questions +- Exact icon/color for the `EXPIRED` tab (proposed: `TimerOff`, `text-orange-500`). +- Whether to add the optional tailored empty-state copy now or defer. \ No newline at end of file diff --git a/apps/remix/app/components/general/document/document-status.tsx b/apps/remix/app/components/general/document/document-status.tsx index 2f8dbbdf4..933e955af 100644 --- a/apps/remix/app/components/general/document/document-status.tsx +++ b/apps/remix/app/components/general/document/document-status.tsx @@ -4,7 +4,7 @@ import { cn } from '@documenso/ui/lib/utils'; import type { MessageDescriptor } from '@lingui/core'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; -import { CheckCircle2, Clock, File, XCircle } from 'lucide-react'; +import { CheckCircle2, Clock, File, TimerOff, XCircle } from 'lucide-react'; import type { LucideIcon } from 'lucide-react/dist/lucide-react'; import type { HTMLAttributes } from 'react'; @@ -46,6 +46,12 @@ export const FRIENDLY_STATUS_MAP: Record icon: XCircle, color: 'text-red-500 dark:text-red-300', }, + EXPIRED: { + label: msg`Expired`, + labelExtended: msg`Document expired`, + icon: TimerOff, + color: 'text-orange-500 dark:text-orange-300', + }, INBOX: { label: msg`Inbox`, labelExtended: msg`Document inbox`, diff --git a/apps/remix/app/components/tables/documents-table-empty-state.tsx b/apps/remix/app/components/tables/documents-table-empty-state.tsx index 8f8dde45c..918b0f358 100644 --- a/apps/remix/app/components/tables/documents-table-empty-state.tsx +++ b/apps/remix/app/components/tables/documents-table-empty-state.tsx @@ -1,7 +1,7 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; -import { Bird, CheckCircle2, XCircle } from 'lucide-react'; +import { Bird, CheckCircle2, TimerOff, XCircle } from 'lucide-react'; import { match } from 'ts-pattern'; export type DocumentsTableEmptyStateProps = { status: ExtendedDocumentStatus }; @@ -29,6 +29,16 @@ export const DocumentsTableEmptyState = ({ status }: DocumentsTableEmptyStatePro message: msg`There are no cancelled documents. Documents you cancel will remain here as a record that they were distributed.`, icon: XCircle, })) + .with(ExtendedDocumentStatus.REJECTED, () => ({ + title: msg`No rejected documents`, + message: msg`There are no rejected documents. Documents that a recipient declines to sign will appear here.`, + icon: XCircle, + })) + .with(ExtendedDocumentStatus.EXPIRED, () => ({ + title: msg`No expired documents`, + message: msg`There are no documents with expired signing links. You can redistribute a document to renew its expiration.`, + icon: TimerOff, + })) .with(ExtendedDocumentStatus.ALL, () => ({ title: msg`We're all empty`, message: msg`You have not yet created or received any documents. To create a document please upload one.`, diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx index 854283075..3350e29e1 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx @@ -76,6 +76,7 @@ export default function DocumentsPage() { [ExtendedDocumentStatus.COMPLETED]: 0, [ExtendedDocumentStatus.REJECTED]: 0, [ExtendedDocumentStatus.CANCELLED]: 0, + [ExtendedDocumentStatus.EXPIRED]: 0, [ExtendedDocumentStatus.INBOX]: 0, [ExtendedDocumentStatus.ALL]: 0, }); @@ -157,6 +158,8 @@ export default function DocumentsPage() { ExtendedDocumentStatus.COMPLETED, ExtendedDocumentStatus.CANCELLED, ExtendedDocumentStatus.DRAFT, + ExtendedDocumentStatus.REJECTED, + ExtendedDocumentStatus.EXPIRED, ExtendedDocumentStatus.ALL, ] .filter((value) => { diff --git a/packages/app-tests/e2e/api/v2/find-documents.spec.ts b/packages/app-tests/e2e/api/v2/find-documents.spec.ts index e3a4d130e..af916cb7e 100644 --- a/packages/app-tests/e2e/api/v2/find-documents.spec.ts +++ b/packages/app-tests/e2e/api/v2/find-documents.spec.ts @@ -1,7 +1,13 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; import { prisma } from '@documenso/prisma'; -import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client'; +import { + DocumentStatus, + DocumentVisibility, + RecipientRole, + SigningStatus, + TeamMemberRole, +} from '@documenso/prisma/client'; import { seedBlankDocument, seedCompletedDocument, @@ -1560,3 +1566,307 @@ test.describe('Find Documents API - Adversarial: Cross-Team templateId', () => { expect(ownTemplate!.data[0].title).toBe('TeamA Doc from Template'); }); }); + +test.describe('Find Documents API - Expired Recipient Filter', () => { + const PAST = new Date(Date.now() - 24 * 60 * 60 * 1000); + const FUTURE = new Date(Date.now() + 24 * 60 * 60 * 1000); + + test('hasExpiredRecipients=true returns only docs with an expired, unsigned, non-CC recipient', async ({ + request, + }) => { + const { user, team } = await seedUser(); + const { user: recipient } = await seedUser(); + + const { token } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'expired-token', + expiresIn: null, + }); + + const expiredDoc = await seedPendingDocument(user, team.id, [recipient], { + createDocumentOptions: { title: 'Expired Recipient Doc' }, + }); + await prisma.recipient.updateMany({ + where: { envelopeId: expiredDoc.id }, + data: { expiresAt: PAST }, + }); + + const activeDoc = await seedPendingDocument(user, team.id, [recipient], { + createDocumentOptions: { title: 'Active Recipient Doc' }, + }); + await prisma.recipient.updateMany({ + where: { envelopeId: activeDoc.id }, + data: { expiresAt: FUTURE }, + }); + + await seedPendingDocument(user, team.id, [recipient], { + createDocumentOptions: { title: 'No Expiry Doc' }, + }); + + const { json } = await findDocuments(request, token, { hasExpiredRecipients: 'true' }); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Expired Recipient Doc'); + expect(titles).not.toContain('Active Recipient Doc'); + expect(titles).not.toContain('No Expiry Doc'); + expect(json!.count).toBe(1); + }); + + test('hasExpiredRecipients=false (and omitted) does not filter by expiry', async ({ request }) => { + const { user, team } = await seedUser(); + const { user: recipient } = await seedUser(); + + const { token } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'expired-false-token', + expiresIn: null, + }); + + const expiredDoc = await seedPendingDocument(user, team.id, [recipient], { + createDocumentOptions: { title: 'Expired Doc' }, + }); + await prisma.recipient.updateMany({ + where: { envelopeId: expiredDoc.id }, + data: { expiresAt: PAST }, + }); + + await seedPendingDocument(user, team.id, [recipient], { + createDocumentOptions: { title: 'Active Doc' }, + }); + + // "false" must NOT be coerced to true — both docs should be returned. + const { json: falseJson } = await findDocuments(request, token, { hasExpiredRecipients: 'false' }); + expect(falseJson!.count).toBe(2); + + const { json: omittedJson } = await findDocuments(request, token); + expect(omittedJson!.count).toBe(2); + }); + + test('excludes signed and CC recipients from the expired filter', async ({ request }) => { + const { user, team } = await seedUser(); + const { user: recipient } = await seedUser(); + + const { token } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'expired-exclude-token', + expiresIn: null, + }); + + const signedDoc = await seedPendingDocument(user, team.id, [recipient], { + createDocumentOptions: { title: 'Expired but Signed' }, + }); + await prisma.recipient.updateMany({ + where: { envelopeId: signedDoc.id }, + data: { expiresAt: PAST, signingStatus: SigningStatus.SIGNED }, + }); + + const ccDoc = await seedPendingDocument(user, team.id, [recipient], { + createDocumentOptions: { title: 'Expired but CC' }, + }); + await prisma.recipient.updateMany({ + where: { envelopeId: ccDoc.id }, + data: { expiresAt: PAST, role: RecipientRole.CC }, + }); + + const validDoc = await seedPendingDocument(user, team.id, [recipient], { + createDocumentOptions: { title: 'Expired Unsigned Signer' }, + }); + await prisma.recipient.updateMany({ + where: { envelopeId: validDoc.id }, + data: { expiresAt: PAST }, + }); + + const { json } = await findDocuments(request, token, { hasExpiredRecipients: 'true' }); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Expired Unsigned Signer'); + expect(titles).not.toContain('Expired but Signed'); + expect(titles).not.toContain('Expired but CC'); + expect(json!.count).toBe(1); + }); +}); + +// ─── Adversarial: Expired Recipient Filter cross-tenant isolation ──────────── +// The expired filter adds an EXISTS subquery over Recipient. These tests ensure +// that predicate never widens visibility past the caller's team/access scope. + +test.describe('Find Documents API - Adversarial: Cross-Team Expired Recipient Filter', () => { + const PAST = new Date(Date.now() - 24 * 60 * 60 * 1000); + + test('token scoped to team A must NOT see team B docs with expired recipients', async ({ request }) => { + const { user: userA, team: teamA } = await seedUser(); + const { user: userB, team: teamB } = await seedUser(); + const { user: recipient } = await seedUser(); + + const { token: tokenA } = await createApiToken({ + userId: userA.id, + teamId: teamA.id, + tokenName: 'teamA-expired-token', + expiresIn: null, + }); + + // Team A: one expired doc the caller is legitimately allowed to see. + const teamADoc = await seedPendingDocument(userA, teamA.id, [recipient], { + createDocumentOptions: { title: 'TeamA Expired Doc' }, + }); + await prisma.recipient.updateMany({ + where: { envelopeId: teamADoc.id }, + data: { expiresAt: PAST }, + }); + + // Team B: an expired doc that must remain invisible to team A's token. + const teamBDoc = await seedPendingDocument(userB, teamB.id, [recipient], { + createDocumentOptions: { title: 'TeamB Expired Doc' }, + }); + await prisma.recipient.updateMany({ + where: { envelopeId: teamBDoc.id }, + data: { expiresAt: PAST }, + }); + + const { json } = await findDocuments(request, tokenA, { hasExpiredRecipients: 'true' }); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('TeamA Expired Doc'); + expect(titles).not.toContain('TeamB Expired Doc'); + expect(json!.count).toBe(1); + }); + + test('shared recipient email across teams does not leak the other team expired docs', async ({ request }) => { + // A recipient with the SAME email is on expired docs in both teams. The + // filter must still scope strictly to the token's team. + const { user: userA, team: teamA } = await seedUser(); + const { user: userB, team: teamB } = await seedUser(); + const { user: sharedRecipient } = await seedUser(); + + const { token: tokenB } = await createApiToken({ + userId: userB.id, + teamId: teamB.id, + tokenName: 'teamB-expired-token', + expiresIn: null, + }); + + const teamADoc = await seedPendingDocument(userA, teamA.id, [sharedRecipient], { + createDocumentOptions: { title: 'TeamA Shared-Recipient Expired' }, + }); + await prisma.recipient.updateMany({ + where: { envelopeId: teamADoc.id }, + data: { expiresAt: PAST }, + }); + + const teamBDoc = await seedPendingDocument(userB, teamB.id, [sharedRecipient], { + createDocumentOptions: { title: 'TeamB Shared-Recipient Expired' }, + }); + await prisma.recipient.updateMany({ + where: { envelopeId: teamBDoc.id }, + data: { expiresAt: PAST }, + }); + + const { json } = await findDocuments(request, tokenB, { hasExpiredRecipients: 'true' }); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('TeamB Shared-Recipient Expired'); + expect(titles).not.toContain('TeamA Shared-Recipient Expired'); + expect(json!.count).toBe(1); + }); + + test('x-team-id spoofing with status=EXPIRED is rejected for a non-member', async ({ page }) => { + const { team: teamA, owner: ownerA } = await seedTeam(); + const { team: teamB, owner: ownerB } = await seedTeam(); + const { user: recipient } = await seedUser(); + + const teamADoc = await seedPendingDocument(ownerA, teamA.id, [recipient], { + createDocumentOptions: { title: 'TeamA Expired Secret' }, + }); + await prisma.recipient.updateMany({ + where: { envelopeId: teamADoc.id }, + data: { expiresAt: PAST }, + }); + + // ownerB is NOT a member of teamA. + await apiSignin({ page, email: ownerB.email }); + + const res = await trpcQuery(page, 'document.findDocumentsInternal', teamA.id, { + status: 'EXPIRED', + page: 1, + perPage: 100, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('EXPIRED pseudo-status via session only returns the caller team expired docs (positive control)', async ({ + page, + }) => { + const { team: teamA, owner: ownerA } = await seedTeam(); + const { team: teamB, owner: ownerB } = await seedTeam(); + const { user: recipient } = await seedUser(); + + const teamADoc = await seedPendingDocument(ownerA, teamA.id, [recipient], { + createDocumentOptions: { title: 'TeamA Expired Visible' }, + }); + await prisma.recipient.updateMany({ + where: { envelopeId: teamADoc.id }, + data: { expiresAt: PAST }, + }); + + const teamBDoc = await seedPendingDocument(ownerB, teamB.id, [recipient], { + createDocumentOptions: { title: 'TeamB Expired Hidden' }, + }); + await prisma.recipient.updateMany({ + where: { envelopeId: teamBDoc.id }, + data: { expiresAt: PAST }, + }); + + await apiSignin({ page, email: ownerA.email }); + + const res = await trpcQuery(page, 'document.findDocumentsInternal', teamA.id, { + status: 'EXPIRED', + page: 1, + perPage: 100, + }); + + expect(res.ok()).toBeTruthy(); + const data = await res.json(); + const docs = data.result.data.json.data; + const titles = docs.map((d: { title: string }) => d.title); + expect(titles).toContain('TeamA Expired Visible'); + expect(titles).not.toContain('TeamB Expired Hidden'); + }); + + test('EXPIRED stats count is scoped to the caller team and excludes other-team expired docs', async ({ page }) => { + const { team: teamA, owner: ownerA } = await seedTeam(); + const { team: teamB, owner: ownerB } = await seedTeam(); + const { user: recipient } = await seedUser(); + + // One expired doc in team A. + const teamADoc = await seedPendingDocument(ownerA, teamA.id, [recipient], { + createDocumentOptions: { title: 'TeamA Expired For Stats' }, + }); + await prisma.recipient.updateMany({ + where: { envelopeId: teamADoc.id }, + data: { expiresAt: PAST }, + }); + + // Two expired docs in team B — must NOT bleed into team A's EXPIRED count. + for (const title of ['TeamB Expired For Stats 1', 'TeamB Expired For Stats 2']) { + const doc = await seedPendingDocument(ownerB, teamB.id, [recipient], { + createDocumentOptions: { title }, + }); + await prisma.recipient.updateMany({ + where: { envelopeId: doc.id }, + data: { expiresAt: PAST }, + }); + } + + await apiSignin({ page, email: ownerA.email }); + + const res = await trpcQuery(page, 'document.findDocumentsInternal', teamA.id, { + page: 1, + perPage: 100, + }); + + expect(res.ok()).toBeTruthy(); + const data = await res.json(); + expect(data.result.data.json.stats.EXPIRED).toBe(1); + }); +}); diff --git a/packages/app-tests/e2e/api/v2/find-envelopes.spec.ts b/packages/app-tests/e2e/api/v2/find-envelopes.spec.ts index 4b58198be..b3abaef7d 100644 --- a/packages/app-tests/e2e/api/v2/find-envelopes.spec.ts +++ b/packages/app-tests/e2e/api/v2/find-envelopes.spec.ts @@ -1055,3 +1055,120 @@ test.describe('Find Envelopes API - Cross-User Isolation', () => { expect(titles).not.toContain('Member Org Team Env'); }); }); + +test.describe('Find Envelopes API - Expired Recipient Filter', () => { + test('hasExpiredRecipients=true returns only envelopes with an expired, unsigned recipient', async ({ request }) => { + const { user, team } = await seedUser(); + const { user: recipient } = await seedUser(); + + const { token } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'env-expired-token', + expiresIn: null, + }); + + const expiredEnvelope = await seedPendingDocument(user, team.id, [recipient], { + createDocumentOptions: { title: 'Expired Envelope' }, + }); + await prisma.recipient.updateMany({ + where: { envelopeId: expiredEnvelope.id }, + data: { expiresAt: new Date(Date.now() - 24 * 60 * 60 * 1000) }, + }); + + await seedPendingDocument(user, team.id, [recipient], { + createDocumentOptions: { title: 'Active Envelope' }, + }); + + const { json } = await findEnvelopes(request, token, { + type: EnvelopeType.DOCUMENT, + hasExpiredRecipients: 'true', + }); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Expired Envelope'); + expect(titles).not.toContain('Active Envelope'); + expect(json!.count).toBe(1); + }); +}); + +// ─── Adversarial: Expired Recipient Filter cross-tenant isolation ──────────── + +test.describe('Find Envelopes API - Adversarial: Cross-Team Expired Recipient Filter', () => { + const PAST = new Date(Date.now() - 24 * 60 * 60 * 1000); + + test('token scoped to team A must NOT see team B envelopes with expired recipients', async ({ request }) => { + const { user: userA, team: teamA } = await seedUser(); + const { user: userB, team: teamB } = await seedUser(); + const { user: recipient } = await seedUser(); + + const { token: tokenA } = await createApiToken({ + userId: userA.id, + teamId: teamA.id, + tokenName: 'env-teamA-expired-token', + expiresIn: null, + }); + + const teamAEnvelope = await seedPendingDocument(userA, teamA.id, [recipient], { + createDocumentOptions: { title: 'TeamA Expired Envelope' }, + }); + await prisma.recipient.updateMany({ + where: { envelopeId: teamAEnvelope.id }, + data: { expiresAt: PAST }, + }); + + const teamBEnvelope = await seedPendingDocument(userB, teamB.id, [recipient], { + createDocumentOptions: { title: 'TeamB Expired Envelope' }, + }); + await prisma.recipient.updateMany({ + where: { envelopeId: teamBEnvelope.id }, + data: { expiresAt: PAST }, + }); + + const { json } = await findEnvelopes(request, tokenA, { + type: EnvelopeType.DOCUMENT, + hasExpiredRecipients: 'true', + }); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('TeamA Expired Envelope'); + expect(titles).not.toContain('TeamB Expired Envelope'); + expect(json!.count).toBe(1); + }); + + test('shared recipient email across teams does not leak the other team expired envelopes', async ({ request }) => { + const { user: userA, team: teamA } = await seedUser(); + const { user: userB, team: teamB } = await seedUser(); + const { user: sharedRecipient } = await seedUser(); + + const { token: tokenB } = await createApiToken({ + userId: userB.id, + teamId: teamB.id, + tokenName: 'env-teamB-expired-token', + expiresIn: null, + }); + + const teamAEnvelope = await seedPendingDocument(userA, teamA.id, [sharedRecipient], { + createDocumentOptions: { title: 'TeamA Shared Expired Envelope' }, + }); + await prisma.recipient.updateMany({ + where: { envelopeId: teamAEnvelope.id }, + data: { expiresAt: PAST }, + }); + + const teamBEnvelope = await seedPendingDocument(userB, teamB.id, [sharedRecipient], { + createDocumentOptions: { title: 'TeamB Shared Expired Envelope' }, + }); + await prisma.recipient.updateMany({ + where: { envelopeId: teamBEnvelope.id }, + data: { expiresAt: PAST }, + }); + + const { json } = await findEnvelopes(request, tokenB, { + type: EnvelopeType.DOCUMENT, + hasExpiredRecipients: 'true', + }); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('TeamB Shared Expired Envelope'); + expect(titles).not.toContain('TeamA Shared Expired Envelope'); + expect(json!.count).toBe(1); + }); +}); diff --git a/packages/app-tests/e2e/documents/find-documents.spec.ts b/packages/app-tests/e2e/documents/find-documents.spec.ts index e8eee34e6..960a09863 100644 --- a/packages/app-tests/e2e/documents/find-documents.spec.ts +++ b/packages/app-tests/e2e/documents/find-documents.spec.ts @@ -10,7 +10,14 @@ import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations'; import { seedTeam, seedTeamEmail, seedTeamMember } from '@documenso/prisma/seed/teams'; import { seedUser } from '@documenso/prisma/seed/users'; import { expect, test } from '@playwright/test'; -import { DocumentStatus, DocumentVisibility, OrganisationMemberRole, TeamMemberRole } from '@prisma/client'; +import { + DocumentStatus, + DocumentVisibility, + OrganisationMemberRole, + RecipientRole, + SigningStatus, + TeamMemberRole, +} from '@prisma/client'; import { apiSignin, apiSignout } from '../fixtures/authentication'; import { checkDocumentTabCount } from '../fixtures/documents'; @@ -1165,3 +1172,132 @@ test.describe('Find Documents UI - Sender Filter', () => { await expect(page.getByRole('link', { name: 'Member1 Sent Doc' })).toBeVisible(); }); }); + +test.describe('Find Documents UI - Rejected and Expired Tabs', () => { + const PAST = new Date(Date.now() - 24 * 60 * 60 * 1000); + + test('rejected tab lists rejected documents and counts them independently', async ({ page }) => { + const { user: owner, team } = await seedUser(); + const { user: recipient } = await seedUser(); + + // A rejected document: envelope status REJECTED + a recipient who rejected. + const rejectedDoc = await seedPendingDocument(owner, team.id, [recipient], { + createDocumentOptions: { title: 'Rejected Doc' }, + }); + await prisma.envelope.update({ + where: { id: rejectedDoc.id }, + data: { status: DocumentStatus.REJECTED }, + }); + await prisma.recipient.updateMany({ + where: { envelopeId: rejectedDoc.id }, + data: { signingStatus: SigningStatus.REJECTED }, + }); + + // A plain pending document (noise — must not appear under Rejected). + await seedPendingDocument(owner, team.id, [recipient], { + createDocumentOptions: { title: 'Plain Pending Doc' }, + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/documents`, + }); + + await checkDocumentTabCount(page, 'Rejected', 1); + await expect(page.getByRole('link', { name: 'Rejected Doc' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Plain Pending Doc' })).not.toBeVisible(); + }); + + test('expired tab lists documents with an expired recipient and shows empty state otherwise', async ({ page }) => { + const { user: owner, team } = await seedUser(); + const { user: recipient } = await seedUser(); + + const expiredDoc = await seedPendingDocument(owner, team.id, [recipient], { + createDocumentOptions: { title: 'Expired Doc' }, + }); + await prisma.recipient.updateMany({ + where: { envelopeId: expiredDoc.id }, + data: { expiresAt: PAST }, + }); + + // Active pending doc — recipient link not expired. + await seedPendingDocument(owner, team.id, [recipient], { + createDocumentOptions: { title: 'Active Doc' }, + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/documents`, + }); + + // Expired doc is still PENDING, so it appears under both Pending and Expired. + await checkDocumentTabCount(page, 'Pending', 2); + await checkDocumentTabCount(page, 'Expired', 1); + await expect(page.getByRole('link', { name: 'Expired Doc' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Active Doc' })).not.toBeVisible(); + }); + + test('expired tab excludes signed and CC recipients', async ({ page }) => { + const { user: owner, team } = await seedUser(); + const { user: recipient } = await seedUser(); + + // Expired but already signed — must NOT count as expired. + const signedDoc = await seedPendingDocument(owner, team.id, [recipient], { + createDocumentOptions: { title: 'Expired Signed Doc' }, + }); + await prisma.recipient.updateMany({ + where: { envelopeId: signedDoc.id }, + data: { expiresAt: PAST, signingStatus: SigningStatus.SIGNED }, + }); + + // Expired but CC — must NOT count as expired. + const ccDoc = await seedPendingDocument(owner, team.id, [recipient], { + createDocumentOptions: { title: 'Expired CC Doc' }, + }); + await prisma.recipient.updateMany({ + where: { envelopeId: ccDoc.id }, + data: { expiresAt: PAST, role: RecipientRole.CC }, + }); + + // Expired, unsigned, non-CC — the only one that should appear. + const validDoc = await seedPendingDocument(owner, team.id, [recipient], { + createDocumentOptions: { title: 'Expired Valid Doc' }, + }); + await prisma.recipient.updateMany({ + where: { envelopeId: validDoc.id }, + data: { expiresAt: PAST }, + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/documents`, + }); + + await checkDocumentTabCount(page, 'Expired', 1); + await expect(page.getByRole('link', { name: 'Expired Valid Doc' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Expired Signed Doc' })).not.toBeVisible(); + await expect(page.getByRole('link', { name: 'Expired CC Doc' })).not.toBeVisible(); + }); + + test('rejected and expired tabs show tailored empty states when nothing matches', async ({ page }) => { + const { user: owner, team } = await seedUser(); + const { user: recipient } = await seedUser(); + + await seedPendingDocument(owner, team.id, [recipient], { + createDocumentOptions: { title: 'Just Pending' }, + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/documents`, + }); + + // count === 0 asserts the empty-document-state is visible. + await checkDocumentTabCount(page, 'Rejected', 0); + await checkDocumentTabCount(page, 'Expired', 0); + }); +}); diff --git a/packages/lib/server-only/admin/get-documents-stats.ts b/packages/lib/server-only/admin/get-documents-stats.ts index 1508f9dd5..15fed8ff7 100644 --- a/packages/lib/server-only/admin/get-documents-stats.ts +++ b/packages/lib/server-only/admin/get-documents-stats.ts @@ -13,7 +13,7 @@ export const getDocumentStats = async () => { }, }); - const stats: Record, number> = { + const stats: Record, number> = { [ExtendedDocumentStatus.DRAFT]: 0, [ExtendedDocumentStatus.PENDING]: 0, [ExtendedDocumentStatus.COMPLETED]: 0, diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index 7a0f67bc7..031ddfb5b 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -16,6 +16,7 @@ import { match } from 'ts-pattern'; import type { FindResultResponse } from '../../types/search-params'; import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document'; +import { hasExpiredRecipient } from '../envelope/query-helpers'; import { getTeamById } from '../team/get-team'; export type PeriodSelectorValue = '' | '7d' | '14d' | '30d'; @@ -36,6 +37,11 @@ export type FindDocumentsOptions = { senderIds?: number[]; query?: string; folderId?: string; + /** + * When true, restrict results to envelopes with at least one recipient whose signing + * link has expired. Orthogonal to `status` — applied additively. + */ + hasExpiredRecipients?: boolean; /** * When true (default), use a windowed count that caps early for faster pagination. * When false, use a full COUNT(*) for exact totals — preferred for external API consumers. @@ -115,6 +121,7 @@ export const findDocuments = async ({ senderIds, query = '', folderId, + hasExpiredRecipients, useWindowedCount = true, }: FindDocumentsOptions) => { const user = await prisma.user.findFirstOrThrow({ @@ -199,6 +206,11 @@ export const findDocuments = async ({ ); } + // Expired recipient filter (orthogonal to status, additive) + if (hasExpiredRecipients) { + qb = qb.where((eb) => hasExpiredRecipient(eb)); + } + return qb; }; @@ -305,6 +317,15 @@ export const findDocuments = async ({ ]), ), ) + .with(ExtendedDocumentStatus.EXPIRED, () => + qb.where((eb) => + eb.and([ + personalDeletedFilter(eb), + hasExpiredRecipient(eb), + eb.or([eb('Envelope.userId', '=', user.id), recipientExists(eb, user.email)]), + ]), + ), + ) .exhaustive(); }; @@ -455,6 +476,18 @@ export const findDocuments = async ({ return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]); }), ) + .with(ExtendedDocumentStatus.EXPIRED, () => + qb.where((eb) => { + const accessBranches = [eb('Envelope.teamId', '=', teamData.id)]; + + if (teamEmail) { + accessBranches.push(senderEmailIs(eb, teamEmail)); + accessBranches.push(recipientExists(eb, teamEmail)); + } + + return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), hasExpiredRecipient(eb), eb.or(accessBranches)]); + }), + ) .exhaustive(); }; diff --git a/packages/lib/server-only/document/get-stats.ts b/packages/lib/server-only/document/get-stats.ts index c1af421f7..e3a27534d 100644 --- a/packages/lib/server-only/document/get-stats.ts +++ b/packages/lib/server-only/document/get-stats.ts @@ -8,6 +8,7 @@ import { DateTime } from 'luxon'; import { STATS_COUNT_CAP } from '../../constants/document'; import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams'; +import { hasExpiredRecipient } from '../envelope/query-helpers'; import { getTeamById } from '../team/get-team'; // Kysely query builder type for Envelope queries. @@ -253,6 +254,19 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId, return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]); }); + // EXPIRED: docs visible to the team/user with at least one expired, unsigned recipient. + // Access control mirrors the EXPIRED branch in findDocuments so the count matches the listing. + const expiredQuery = buildBaseQuery().where((eb) => { + const accessBranches = [eb('Envelope.teamId', '=', team.id)]; + + if (teamEmail) { + accessBranches.push(senderEmailIs(eb, teamEmail)); + accessBranches.push(recipientExists(eb, teamEmail)); + } + + return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), hasExpiredRecipient(eb), eb.or(accessBranches)]); + }); + // INBOX: non-draft docs where team email is a NOT_SIGNED, non-CC recipient // Returns 0 if the team has no team email. const inboxQuery = teamEmail @@ -274,15 +288,17 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId, // ─── Execute all counts in parallel ────────────────────────────────── - const [draft, pending, completed, rejected, cancelled, inbox] = await Promise.all([ + const [draft, pending, completed, rejected, cancelled, expired, inbox] = await Promise.all([ cappedCount(draftQuery), cappedCount(pendingQuery), cappedCount(completedQuery), cappedCount(rejectedQuery), cappedCount(cancelledQuery), + cappedCount(expiredQuery), inboxQuery ? cappedCount(inboxQuery) : Promise.resolve(0), ]); + // `expired` is intentionally excluded from `all` — it overlaps PENDING. const all = Math.min(draft + pending + completed + rejected + cancelled + inbox, STATS_COUNT_CAP); const stats: Record = { @@ -291,6 +307,7 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId, [ExtendedDocumentStatus.COMPLETED]: completed, [ExtendedDocumentStatus.REJECTED]: rejected, [ExtendedDocumentStatus.CANCELLED]: cancelled, + [ExtendedDocumentStatus.EXPIRED]: expired, [ExtendedDocumentStatus.INBOX]: inbox, [ExtendedDocumentStatus.ALL]: all, }; diff --git a/packages/lib/server-only/envelope/find-envelopes.ts b/packages/lib/server-only/envelope/find-envelopes.ts index 69bab6939..af37d704b 100644 --- a/packages/lib/server-only/envelope/find-envelopes.ts +++ b/packages/lib/server-only/envelope/find-envelopes.ts @@ -7,6 +7,7 @@ import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams'; import type { FindResultResponse } from '../../types/search-params'; import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document'; import { getTeamById } from '../team/get-team'; +import { hasExpiredRecipient } from './query-helpers'; export type FindEnvelopesOptions = { userId: number; @@ -23,6 +24,11 @@ export type FindEnvelopesOptions = { }; query?: string; folderId?: string; + /** + * When true, restrict results to envelopes with at least one recipient whose signing + * link has expired. Orthogonal to `status` — applied additively. + */ + hasExpiredRecipients?: boolean; /** * When true (default), use a windowed count that caps early for faster pagination. * When false, use a full COUNT(*) for exact totals — preferred for external API consumers. @@ -106,6 +112,7 @@ export const findEnvelopes = async ({ orderBy, query = '', folderId, + hasExpiredRecipients, useWindowedCount = true, }: FindEnvelopesOptions) => { const user = await prisma.user.findFirstOrThrow({ @@ -182,6 +189,11 @@ export const findEnvelopes = async ({ ); } + // Expired recipient filter (orthogonal to status, additive) + if (hasExpiredRecipients) { + qb = qb.where((eb) => hasExpiredRecipient(eb)); + } + // ─── Access control ────────────────────────────────────────────────── // // An envelope is visible if ANY of: diff --git a/packages/lib/server-only/envelope/query-helpers.ts b/packages/lib/server-only/envelope/query-helpers.ts new file mode 100644 index 000000000..57003e9d3 --- /dev/null +++ b/packages/lib/server-only/envelope/query-helpers.ts @@ -0,0 +1,27 @@ +import { sql } from '@documenso/prisma'; +import type { DB } from '@documenso/prisma/generated/types'; +import { RecipientRole, SigningStatus } from '@prisma/client'; +import type { ExpressionBuilder } from 'kysely'; + +// Expression builder type scoped to the Envelope table context. +type EnvelopeExpressionBuilder = ExpressionBuilder; + +/** + * Reusable EXISTS subquery: checks that the envelope has at least one recipient whose + * signing link has expired — `expiresAt` in the past, still unsigned, and not a CC. + * + * This is the single source of truth for the "expired recipient" predicate used by + * `findDocuments`, `findEnvelopes`, and `getStats`. It must stay in sync with + * `isRecipientExpired` (packages/lib/utils/recipients.ts). + */ +export const hasExpiredRecipient = (eb: EnvelopeExpressionBuilder) => + eb.exists( + eb + .selectFrom('Recipient') + .whereRef('Recipient.envelopeId', '=', 'Envelope.id') + .where('Recipient.expiresAt', 'is not', null) + .where('Recipient.expiresAt', '<=', new Date()) + .where('Recipient.signingStatus', '=', sql.lit(SigningStatus.NOT_SIGNED)) + .where('Recipient.role', '!=', sql.lit(RecipientRole.CC)) + .select(sql.lit(1).as('one')), + ); diff --git a/packages/prisma/types/extended-document-status.ts b/packages/prisma/types/extended-document-status.ts index 18f01d4bb..ddd9f693e 100644 --- a/packages/prisma/types/extended-document-status.ts +++ b/packages/prisma/types/extended-document-status.ts @@ -4,6 +4,7 @@ export const ExtendedDocumentStatus = { ...DocumentStatus, INBOX: 'INBOX', ALL: 'ALL', + EXPIRED: 'EXPIRED', } as const; export type ExtendedDocumentStatus = (typeof ExtendedDocumentStatus)[keyof typeof ExtendedDocumentStatus]; diff --git a/packages/trpc/server/document-router/find-documents-internal.ts b/packages/trpc/server/document-router/find-documents-internal.ts index ff31b882d..5503e3a0b 100644 --- a/packages/trpc/server/document-router/find-documents-internal.ts +++ b/packages/trpc/server/document-router/find-documents-internal.ts @@ -23,6 +23,7 @@ export const findDocumentsInternalRoute = authenticatedProcedure orderByColumn, source, status, + hasExpiredRecipients, period, senderIds, folderId, @@ -49,6 +50,7 @@ export const findDocumentsInternalRoute = authenticatedProcedure period, senderIds, folderId, + hasExpiredRecipients, orderBy: orderByColumn ? { column: orderByColumn, direction: orderByDirection } : undefined, }), ]); diff --git a/packages/trpc/server/document-router/find-documents-internal.types.ts b/packages/trpc/server/document-router/find-documents-internal.types.ts index 453daaf2b..99d903ae5 100644 --- a/packages/trpc/server/document-router/find-documents-internal.types.ts +++ b/packages/trpc/server/document-router/find-documents-internal.types.ts @@ -20,6 +20,7 @@ export const ZFindDocumentsInternalResponseSchema = ZFindResultResponse.extend({ [ExtendedDocumentStatus.COMPLETED]: z.number(), [ExtendedDocumentStatus.REJECTED]: z.number(), [ExtendedDocumentStatus.CANCELLED]: z.number(), + [ExtendedDocumentStatus.EXPIRED]: z.number(), [ExtendedDocumentStatus.INBOX]: z.number(), [ExtendedDocumentStatus.ALL]: z.number(), }), diff --git a/packages/trpc/server/document-router/find-documents.ts b/packages/trpc/server/document-router/find-documents.ts index 475a6b5b6..f82e8b1ff 100644 --- a/packages/trpc/server/document-router/find-documents.ts +++ b/packages/trpc/server/document-router/find-documents.ts @@ -11,7 +11,18 @@ export const findDocumentsRoute = authenticatedProcedure .query(async ({ input, ctx }) => { const { user, teamId } = ctx; - const { query, templateId, page, perPage, orderByDirection, orderByColumn, source, status, folderId } = input; + const { + query, + templateId, + page, + perPage, + orderByDirection, + orderByColumn, + source, + status, + hasExpiredRecipients, + folderId, + } = input; const documents = await findDocuments({ userId: user.id, @@ -20,6 +31,7 @@ export const findDocumentsRoute = authenticatedProcedure query, source, status, + hasExpiredRecipients, page, perPage, folderId, diff --git a/packages/trpc/server/document-router/find-documents.types.ts b/packages/trpc/server/document-router/find-documents.types.ts index b41b9456c..da8b40eee 100644 --- a/packages/trpc/server/document-router/find-documents.types.ts +++ b/packages/trpc/server/document-router/find-documents.types.ts @@ -21,6 +21,11 @@ export const ZFindDocumentsRequestSchema = ZFindSearchParamsSchema.extend({ templateId: z.number().describe('Filter documents by the template ID used to create it.').optional(), source: z.nativeEnum(DocumentSource).describe('Filter documents by how it was created.').optional(), status: z.nativeEnum(DocumentStatus).describe('Filter documents by the current status').optional(), + hasExpiredRecipients: z + .enum(['true', 'false']) + .describe('Filter for documents that have at least one recipient whose signing link has expired.') + .transform((value) => value === 'true') + .optional(), folderId: z.string().describe('Filter documents by folder ID').optional(), orderByColumn: z.enum(['createdAt']).optional(), orderByDirection: z.enum(['asc', 'desc']).describe('').default('desc'), diff --git a/packages/trpc/server/document-router/redistribute-document.types.ts b/packages/trpc/server/document-router/redistribute-document.types.ts index 7fde6c373..05f7efe4d 100644 --- a/packages/trpc/server/document-router/redistribute-document.types.ts +++ b/packages/trpc/server/document-router/redistribute-document.types.ts @@ -9,7 +9,7 @@ export const redistributeDocumentMeta: TrpcRouteMeta = { path: '/document/redistribute', summary: 'Redistribute document', description: - 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Redistribute the document to the provided recipients who have not actioned the document. Will use the distribution method set in the document', + 'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Redistribute the document to the provided recipients who have not actioned the document. Will use the distribution method set in the document. This also refreshes the signing-link expiration for the targeted unsigned recipients, renewing any expired links.', tags: ['Document'], deprecated: true, }, diff --git a/packages/trpc/server/envelope-router/find-envelopes.ts b/packages/trpc/server/envelope-router/find-envelopes.ts index b05faea7e..a845c0723 100644 --- a/packages/trpc/server/envelope-router/find-envelopes.ts +++ b/packages/trpc/server/envelope-router/find-envelopes.ts @@ -10,7 +10,19 @@ export const findEnvelopesRoute = authenticatedProcedure .query(async ({ input, ctx }) => { const { user, teamId } = ctx; - const { query, type, templateId, page, perPage, orderByDirection, orderByColumn, source, status, folderId } = input; + const { + query, + type, + templateId, + page, + perPage, + orderByDirection, + orderByColumn, + source, + status, + hasExpiredRecipients, + folderId, + } = input; ctx.logger.info({ input: { @@ -19,6 +31,7 @@ export const findEnvelopesRoute = authenticatedProcedure templateId, source, status, + hasExpiredRecipients, folderId, page, perPage, @@ -33,6 +46,7 @@ export const findEnvelopesRoute = authenticatedProcedure query, source, status, + hasExpiredRecipients, page, perPage, folderId, diff --git a/packages/trpc/server/envelope-router/find-envelopes.types.ts b/packages/trpc/server/envelope-router/find-envelopes.types.ts index a1c4f2ef6..c0a0231a9 100644 --- a/packages/trpc/server/envelope-router/find-envelopes.types.ts +++ b/packages/trpc/server/envelope-router/find-envelopes.types.ts @@ -20,6 +20,11 @@ export const ZFindEnvelopesRequestSchema = ZFindSearchParamsSchema.extend({ templateId: z.number().describe('Filter envelopes by the template ID used to create it.').optional(), source: z.nativeEnum(DocumentSource).describe('Filter envelopes by how it was created.').optional(), status: z.nativeEnum(DocumentStatus).describe('Filter envelopes by the current status.').optional(), + hasExpiredRecipients: z + .enum(['true', 'false']) + .describe('Filter for envelopes that have at least one recipient whose signing link has expired.') + .transform((value) => value === 'true') + .optional(), folderId: z.string().describe('Filter envelopes by folder ID.').optional(), orderByColumn: z.enum(['createdAt']).optional(), orderByDirection: z.enum(['asc', 'desc']).describe('Sort direction.').default('desc'), diff --git a/packages/trpc/server/envelope-router/redistribute-envelope.types.ts b/packages/trpc/server/envelope-router/redistribute-envelope.types.ts index 1926e6bd4..6078ade2a 100644 --- a/packages/trpc/server/envelope-router/redistribute-envelope.types.ts +++ b/packages/trpc/server/envelope-router/redistribute-envelope.types.ts @@ -10,7 +10,7 @@ export const redistributeEnvelopeMeta: TrpcRouteMeta = { path: '/envelope/redistribute', summary: 'Redistribute envelope', description: - 'Redistribute the envelope to the provided recipients who have not actioned the envelope. Will use the distribution method set in the envelope', + 'Redistribute the envelope to the provided recipients who have not actioned the envelope. Will use the distribution method set in the envelope. This also refreshes the signing-link expiration for the targeted unsigned recipients, renewing any expired links.', tags: ['Envelope'], }, }; From 29020bcbedcbefb428e8f7d04804436e44a5c6fa Mon Sep 17 00:00:00 2001 From: Ephraim Duncan Date: Mon, 3 Aug 2026 02:16:39 +0000 Subject: [PATCH 12/27] docs(webhooks): correct retry policy, timeout and payload reference (#3132) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Corrects the webhooks documentation, which described delivery behavior that does not exist in the implementation. ## Changes Made - Replaced the fabricated retry schedule (5 attempts / immediate-to-2h backoff) with the real provider-dependent behavior: retries belong to the job provider (`NEXT_PRIVATE_JOBS_PROVIDER`) — local (default) 4 total attempts back-to-back, BullMQ 3 attempts with exponential backoff from 1s, Inngest 5 attempts with platform backoff. - Fixed the webhook timeout from 30 seconds to 10 seconds (`WEBHOOK_TIMEOUT_MS = 10_000`, hard abort). - Clarified failure semantics: non-2xx fails, 3xx redirects are not followed (`redirect: 'manual'`), network/SSRF-blocked calls record response code 0; failed deliveries mark only the `WebhookCall` record — the webhook itself is never auto-disabled. - Corrected URL requirements: `http://` is accepted; documented the SSRF guard (private/loopback blocked, `NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS` bypass for self-hosters). - Added `envelopeId` to both field tables and all payload/recipient JSON examples; framed numeric `id` as the legacy v1 identifier. - Removed a documented `documentMeta` field that exists in neither the Zod schema nor Prisma; fixed timezone/dateFormat examples to the hardcoded `Etc/UTC` / `yyyy-MM-dd hh:mm a` values. - Added missing `REJECTED`/`CANCELLED` statuses and `TEMPLATE_DIRECT_LINK` source; fixed `templateId` to `null` on TEMPLATE_* examples; documented the previously missing `RECIPIENT_EXPIRED` event across setup, events, and verification pages. ## Testing Performed Docs-only change. Every claim verified against the implementation (`execute-webhook-call.ts`, job clients, `webhook-payload.ts`, `assert-webhook-url.ts`, webhook-router schema). --- .../docs/developers/webhooks/events.mdx | 115 ++++++++++++++---- .../docs/developers/webhooks/index.mdx | 6 +- .../docs/developers/webhooks/setup.mdx | 39 +++--- .../docs/developers/webhooks/verification.mdx | 1 + 4 files changed, 122 insertions(+), 39 deletions(-) diff --git a/apps/docs/content/docs/developers/webhooks/events.mdx b/apps/docs/content/docs/developers/webhooks/events.mdx index 9f63f78ac..5bfaa7a42 100644 --- a/apps/docs/content/docs/developers/webhooks/events.mdx +++ b/apps/docs/content/docs/developers/webhooks/events.mdx @@ -33,13 +33,14 @@ All webhook events share a common structure: | Field | Type | Description | | ---------------- | --------- | ------------------------------------------------------ | -| `id` | number | Document or template ID | +| `id` | number | Legacy numeric v1 document or template ID | +| `envelopeId` | string | Canonical v2 identifier (`envelope_` + 16 characters) | | `externalId` | string? | External identifier for integration | | `userId` | number | Owner's user ID | | `authOptions` | object? | Document-level authentication options | | `formValues` | object? | PDF form values associated with the document | | `title` | string | Document or template title | -| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED` | +| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, `CANCELLED` | | `visibility` | string | Document visibility setting | | `createdAt` | datetime | Document creation timestamp | | `updatedAt` | datetime | Last modification timestamp | @@ -47,8 +48,8 @@ All webhook events share a common structure: | `deletedAt` | datetime? | Deletion timestamp | | `teamId` | number? | Team ID if document belongs to a team | | `templateId` | number? | Template ID if created from a template | -| `source` | string | Source: `DOCUMENT` or `TEMPLATE` | -| `documentMeta` | object | Document metadata (subject, message, signing options) | +| `source` | string | Source: `DOCUMENT`, `TEMPLATE`, or `TEMPLATE_DIRECT_LINK` | +| `documentMeta` | object? | Nullable document metadata (subject, message, signing options) | | `recipients` | array | List of recipient objects | | `Recipient` | array | List of recipient objects (legacy, same as recipients) | @@ -60,7 +61,6 @@ All webhook events share a common structure: | `subject` | string? | Email subject line | | `message` | string? | Email message body | | `timezone` | string | Timezone for date display | -| `password` | string? | Document access password (if set) | | `dateFormat` | string | Date format string | | `redirectUrl` | string? | URL to redirect after signing | | `signingOrder` | string | `PARALLEL` or `SEQUENTIAL` | @@ -77,8 +77,9 @@ All webhook events share a common structure: | Field | Type | Description | | ---------------------- | --------- | ------------------------------------------ | | `id` | number | Recipient ID | -| `documentId` | number? | Parent document ID | -| `templateId` | number? | Template ID if created from a template | +| `envelopeId` | string | Canonical parent envelope ID | +| `documentId` | number? | Legacy parent document ID; null for templates | +| `templateId` | number? | Legacy parent template ID; null for documents | | `email` | string | Recipient email address | | `name` | string | Recipient name | | `token` | string | Unique signing token | @@ -94,6 +95,8 @@ All webhook events share a common structure: | `sendStatus` | string | `NOT_SENT` or `SENT` | | `rejectionReason` | string? | Reason if recipient rejected | +Use `recipient.envelopeId` as the reliable parent link. The legacy `documentId` and `templateId` fields depend on the parent envelope type, so one of them is always null. + --- ## Document Lifecycle Events @@ -111,6 +114,7 @@ Triggered when a new document is created. "event": "DOCUMENT_CREATED", "payload": { "id": 10, + "envelopeId": "envelope_abcdefhiklmnorst", "externalId": null, "userId": 1, "authOptions": null, @@ -129,9 +133,8 @@ Triggered when a new document is created. "id": "doc_meta_123", "subject": "Please sign this document", "message": "Hello, please review and sign this document.", - "timezone": "UTC", - "password": null, - "dateFormat": "MM/DD/YYYY", + "timezone": "Etc/UTC", + "dateFormat": "yyyy-MM-dd hh:mm a", "redirectUrl": null, "signingOrder": "PARALLEL", "allowDictateNextSigner": false, @@ -145,6 +148,7 @@ Triggered when a new document is created. "recipients": [ { "id": 52, + "envelopeId": "envelope_abcdefhiklmnorst", "documentId": 10, "templateId": null, "email": "signer@example.com", @@ -166,6 +170,7 @@ Triggered when a new document is created. "Recipient": [ { "id": 52, + "envelopeId": "envelope_abcdefhiklmnorst", "documentId": 10, "templateId": null, "email": "signer@example.com", @@ -203,6 +208,7 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT" "event": "DOCUMENT_SENT", "payload": { "id": 10, + "envelopeId": "envelope_abcdefhiklmnorst", "externalId": null, "userId": 1, "authOptions": null, @@ -221,9 +227,8 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT" "id": "doc_meta_123", "subject": "Please sign this document", "message": "Hello, please review and sign this document.", - "timezone": "UTC", - "password": null, - "dateFormat": "MM/DD/YYYY", + "timezone": "Etc/UTC", + "dateFormat": "yyyy-MM-dd hh:mm a", "redirectUrl": null, "signingOrder": "PARALLEL", "allowDictateNextSigner": false, @@ -237,6 +242,7 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT" "recipients": [ { "id": 52, + "envelopeId": "envelope_abcdefhiklmnorst", "documentId": 10, "templateId": null, "email": "signer@example.com", @@ -258,6 +264,7 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT" "Recipient": [ { "id": 52, + "envelopeId": "envelope_abcdefhiklmnorst", "documentId": 10, "templateId": null, "email": "signer@example.com", @@ -295,12 +302,14 @@ The recipient's `readStatus` changes to `OPENED`. "event": "DOCUMENT_OPENED", "payload": { "id": 10, + "envelopeId": "envelope_abcdefhiklmnorst", "status": "PENDING", "title": "contract.pdf", "source": "DOCUMENT", "recipients": [ { "id": 52, + "envelopeId": "envelope_abcdefhiklmnorst", "email": "signer@example.com", "name": "John Doe", "role": "SIGNER", @@ -328,6 +337,7 @@ The recipient's `signingStatus` changes to `SIGNED` and `signedAt` is populated. "event": "DOCUMENT_SIGNED", "payload": { "id": 10, + "envelopeId": "envelope_abcdefhiklmnorst", "status": "COMPLETED", "title": "contract.pdf", "source": "DOCUMENT", @@ -335,6 +345,7 @@ The recipient's `signingStatus` changes to `SIGNED` and `signedAt` is populated. "recipients": [ { "id": 51, + "envelopeId": "envelope_abcdefhiklmnorst", "email": "signer@example.com", "name": "John Doe", "role": "SIGNER", @@ -361,12 +372,14 @@ Triggered when an individual recipient completes their required action (signing, "event": "DOCUMENT_RECIPIENT_COMPLETED", "payload": { "id": 10, + "envelopeId": "envelope_abcdefhiklmnorst", "status": "PENDING", "title": "contract.pdf", "source": "DOCUMENT", "recipients": [ { "id": 52, + "envelopeId": "envelope_abcdefhiklmnorst", "email": "signer@example.com", "name": "John Doe", "role": "SIGNER", @@ -395,6 +408,7 @@ The document status changes to `COMPLETED` and `completedAt` is set. "event": "DOCUMENT_COMPLETED", "payload": { "id": 10, + "envelopeId": "envelope_abcdefhiklmnorst", "externalId": null, "userId": 1, "authOptions": null, @@ -413,9 +427,8 @@ The document status changes to `COMPLETED` and `completedAt` is set. "id": "doc_meta_123", "subject": "Please sign this document", "message": "Hello, please review and sign this document.", - "timezone": "UTC", - "password": null, - "dateFormat": "MM/DD/YYYY", + "timezone": "Etc/UTC", + "dateFormat": "yyyy-MM-dd hh:mm a", "redirectUrl": null, "signingOrder": "PARALLEL", "allowDictateNextSigner": false, @@ -429,6 +442,7 @@ The document status changes to `COMPLETED` and `completedAt` is set. "recipients": [ { "id": 50, + "envelopeId": "envelope_abcdefhiklmnorst", "documentId": 10, "templateId": null, "email": "reviewer@example.com", @@ -451,6 +465,7 @@ The document status changes to `COMPLETED` and `completedAt` is set. }, { "id": 51, + "envelopeId": "envelope_abcdefhiklmnorst", "documentId": 10, "templateId": null, "email": "signer@example.com", @@ -475,6 +490,7 @@ The document status changes to `COMPLETED` and `completedAt` is set. "Recipient": [ { "id": 50, + "envelopeId": "envelope_abcdefhiklmnorst", "documentId": 10, "templateId": null, "email": "reviewer@example.com", @@ -497,6 +513,7 @@ The document status changes to `COMPLETED` and `completedAt` is set. }, { "id": 51, + "envelopeId": "envelope_abcdefhiklmnorst", "documentId": 10, "templateId": null, "email": "signer@example.com", @@ -537,12 +554,14 @@ The recipient's `signingStatus` changes to `REJECTED` and `rejectionReason` cont "event": "DOCUMENT_REJECTED", "payload": { "id": 10, + "envelopeId": "envelope_abcdefhiklmnorst", "status": "PENDING", "title": "contract.pdf", "source": "DOCUMENT", "recipients": [ { "id": 52, + "envelopeId": "envelope_abcdefhiklmnorst", "email": "signer@example.com", "name": "John Doe", "role": "SIGNER", @@ -561,7 +580,7 @@ The recipient's `signingStatus` changes to `REJECTED` and `rejectionReason` cont ### `document.cancelled` -Triggered when the document owner or a team member deletes a document. Draft and pending documents are hard-deleted, while completed documents are soft-deleted. +Triggered when a pending document is explicitly cancelled with `POST /envelope/cancel`, or when a document owner or team member deletes a document. Deleting a draft or pending document hard-deletes it, while deleting a completed document soft-deletes it. This event is **not** triggered when a recipient hides a document from their inbox. @@ -572,6 +591,7 @@ This event is **not** triggered when a recipient hides a document from their inb "event": "DOCUMENT_CANCELLED", "payload": { "id": 7, + "envelopeId": "envelope_abcdefhiklmnorst", "externalId": null, "userId": 3, "authOptions": null, @@ -591,7 +611,6 @@ This event is **not** triggered when a recipient hides a document from their inb "subject": "", "message": "", "timezone": "Etc/UTC", - "password": null, "dateFormat": "yyyy-MM-dd hh:mm a", "redirectUrl": "", "signingOrder": "PARALLEL", @@ -606,6 +625,7 @@ This event is **not** triggered when a recipient hides a document from their inb "recipients": [ { "id": 7, + "envelopeId": "envelope_abcdefhiklmnorst", "documentId": 7, "templateId": null, "email": "signer@example.com", @@ -627,6 +647,7 @@ This event is **not** triggered when a recipient hides a document from their inb "Recipient": [ { "id": 7, + "envelopeId": "envelope_abcdefhiklmnorst", "documentId": 7, "templateId": null, "email": "signer@example.com", @@ -651,6 +672,45 @@ This event is **not** triggered when a recipient hides a document from their inb } ``` +### `recipient.expired` + +Triggered when a recipient's signing deadline passes on a pending document before they sign or reject it. + +**Event name:** `RECIPIENT_EXPIRED` + +The recipient's `expiresAt` contains the signing deadline, and `expirationNotifiedAt` is set when the expiration is processed. + +```json +{ + "event": "RECIPIENT_EXPIRED", + "payload": { + "id": 10, + "envelopeId": "envelope_abcdefhiklmnorst", + "status": "PENDING", + "title": "contract.pdf", + "source": "DOCUMENT", + "recipients": [ + { + "id": 52, + "envelopeId": "envelope_abcdefhiklmnorst", + "documentId": 10, + "templateId": null, + "email": "signer@example.com", + "name": "John Doe", + "role": "SIGNER", + "expiresAt": "2024-04-22T11:51:00.000Z", + "expirationNotifiedAt": "2024-04-22T11:52:00.000Z", + "readStatus": "OPENED", + "signingStatus": "NOT_SIGNED", + "sendStatus": "SENT" + } + ] + }, + "createdAt": "2024-04-22T11:52:00.000Z", + "webhookEndpoint": "https://your-endpoint.com/webhook" +} +``` + ### `document.reminder.sent` Triggered when a reminder email is sent to a recipient who has not yet completed their action. @@ -662,12 +722,14 @@ Triggered when a reminder email is sent to a recipient who has not yet completed "event": "DOCUMENT_REMINDER_SENT", "payload": { "id": 10, + "envelopeId": "envelope_abcdefhiklmnorst", "status": "PENDING", "title": "contract.pdf", "source": "DOCUMENT", "recipients": [ { "id": 52, + "envelopeId": "envelope_abcdefhiklmnorst", "email": "signer@example.com", "name": "John Doe", "role": "SIGNER", @@ -686,7 +748,7 @@ Triggered when a reminder email is sent to a recipient who has not yet completed ## Template Events -Template events track changes to reusable document templates. Template payloads use the same structure as document payloads, with `source` set to `TEMPLATE` and `templateId` populated. +Template events track changes to reusable document templates. Template payloads use the same structure as document payloads. For `TEMPLATE_CREATED`, `TEMPLATE_UPDATED`, and `TEMPLATE_DELETED` the template's own legacy numeric ID is in `id` and `templateId` is `null`. Only `TEMPLATE_USED` — whose payload describes the new document envelope created from the template — carries the originating template's legacy ID in `templateId`, with `source` set to `TEMPLATE`. ### `template.created` @@ -699,9 +761,10 @@ Triggered when a new template is created. "event": "TEMPLATE_CREATED", "payload": { "id": 10, + "envelopeId": "envelope_abcdefhiklmnorst", "title": "My Template", "status": "DRAFT", - "templateId": 10, + "templateId": null, "source": "TEMPLATE", "recipients": [] }, @@ -721,9 +784,10 @@ Triggered when a template's settings, recipients, or fields are modified. "event": "TEMPLATE_UPDATED", "payload": { "id": 10, + "envelopeId": "envelope_abcdefhiklmnorst", "title": "My Updated Template", "status": "DRAFT", - "templateId": 10, + "templateId": null, "source": "TEMPLATE", "recipients": [] }, @@ -743,9 +807,10 @@ Triggered when a template is deleted. "event": "TEMPLATE_DELETED", "payload": { "id": 10, + "envelopeId": "envelope_abcdefhiklmnorst", "title": "Deleted Template", "status": "DRAFT", - "templateId": 10, + "templateId": null, "source": "TEMPLATE", "recipients": [] }, @@ -765,6 +830,7 @@ Triggered when a document is created from a template. This event fires alongside "event": "TEMPLATE_USED", "payload": { "id": 10, + "envelopeId": "envelope_abcdefhiklmnorst", "title": "Document from Template", "status": "DRAFT", "templateId": 10, @@ -791,7 +857,8 @@ Triggered when a document is created from a template. This event fires alongside | `DOCUMENT_RECIPIENT_COMPLETED` | Recipient completes their action | Recipient `signingStatus: "SIGNED"`, `signedAt` set | | `DOCUMENT_COMPLETED` | All recipients complete actions | `status: "COMPLETED"`, `completedAt` set | | `DOCUMENT_REJECTED` | Recipient rejects document | Recipient `signingStatus: "REJECTED"`, `rejectionReason` set | -| `DOCUMENT_CANCELLED` | Owner or team member deletes document | Document cancelled or deleted | +| `DOCUMENT_CANCELLED` | Pending document explicitly cancelled, or document deleted | `status: "CANCELLED"` after explicit cancellation; deletion may remove or soft-delete the document | +| `RECIPIENT_EXPIRED` | Recipient signing deadline passes | Recipient `expiresAt` passed, `expirationNotifiedAt` set | | `DOCUMENT_REMINDER_SENT` | Reminder email sent to recipient | No status changes | ### Template Events @@ -821,7 +888,7 @@ When processing webhook events: **Process idempotently** — Webhooks may be retried, so handle duplicate events - **Respond quickly** — Return a 200 status code within 30 seconds + **Respond quickly** — Return a `2xx` status code within 10 seconds diff --git a/apps/docs/content/docs/developers/webhooks/index.mdx b/apps/docs/content/docs/developers/webhooks/index.mdx index 14bb89123..8c27eccbd 100644 --- a/apps/docs/content/docs/developers/webhooks/index.mdx +++ b/apps/docs/content/docs/developers/webhooks/index.mdx @@ -9,7 +9,7 @@ description: Receive real-time notifications for document and template events. 2. When an event occurs, Documenso sends an HTTP POST to your URL 3. Your application processes the event and responds with 200 OK -Documenso supports webhook events for the full document lifecycle (created, sent, opened, signed, completed, rejected, cancelled) as well as template events (created, updated, deleted, used). +Documenso supports webhook events for the full document lifecycle (created, sent, opened, signed, completed, rejected, cancelled), recipient-level events (recipient completed, reminder sent, recipient expired), and template events (created, updated, deleted, used). --- @@ -42,12 +42,14 @@ Documenso supports webhook events for the full document lifecycle (created, sent "event": "DOCUMENT_COMPLETED", "payload": { "id": 123, + "envelopeId": "envelope_abcdefhiklmnorst", "title": "Contract", "status": "COMPLETED", "completedAt": "2024-01-15T10:30:00.000Z", "recipients": [ { "id": 1, + "envelopeId": "envelope_abcdefhiklmnorst", "email": "signer@example.com", "signingStatus": "SIGNED" } @@ -58,6 +60,8 @@ Documenso supports webhook events for the full document lifecycle (created, sent } ``` +`payload.id` is the legacy numeric v1 ID. Use `payload.envelopeId` as the canonical v2 identifier. Each recipient repeats `envelopeId` as the reliable parent link because the legacy `documentId` and `templateId` fields depend on the parent envelope type, leaving one of them null. + --- ## See Also diff --git a/apps/docs/content/docs/developers/webhooks/setup.mdx b/apps/docs/content/docs/developers/webhooks/setup.mdx index 1725bec05..88fda57ea 100644 --- a/apps/docs/content/docs/developers/webhooks/setup.mdx +++ b/apps/docs/content/docs/developers/webhooks/setup.mdx @@ -148,7 +148,7 @@ func main() { - Always respond with a `200 OK` status within 30 seconds. Documenso will retry failed deliveries. + Always respond with a `2xx` status within 10 seconds. Documenso will retry failed deliveries according to the configured background-job provider. ## Configuring Webhooks in Documenso via the Dashboard @@ -184,7 +184,7 @@ Fill in the following fields: | Field | Description | | ----- | ----------- | -| **Webhook URL** | The HTTPS endpoint that will receive webhook events | +| **Webhook URL** | The HTTP or HTTPS endpoint that will receive webhook events | | **Events** | Select which events should trigger this webhook | | **Secret** (optional) | A secret key used to sign the payload for verification | @@ -202,12 +202,21 @@ Your webhook endpoint must meet these requirements: | Requirement | Details | | ----------- | ------- | -| **Protocol** | HTTPS required (HTTP not allowed in production) | -| **Response** | Must return `2xx` status code within 30 seconds | +| **Protocol** | HTTP and HTTPS are accepted; use HTTPS in production | +| **Response** | Must return a `2xx` status code within 10 seconds | | **Method** | Must accept HTTP POST requests | | **Content-Type** | Must accept `application/json` payloads | | **Availability** | Must be publicly accessible from the internet | + + Documenso performs a best-effort check that rejects webhook URLs which use or resolve to private + or loopback addresses. This is not a complete SSRF mitigation — it does not cover DNS rebinding + and fails open on DNS lookup errors or timeouts — so self-hosted deployments should still enforce + network-level egress rules. Self-hosters that need to deliver to a hostname resolving to a + private address can add that hostname to the comma-separated + `NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS` environment variable. + + For local development, use a tunneling service like [ngrok](https://ngrok.com) or [localtunnel](https://localtunnel.me) to expose your local server. @@ -225,7 +234,8 @@ When creating a webhook, you can subscribe to one or more events: | `DOCUMENT_RECIPIENT_COMPLETED` | A recipient completes their required action | | `DOCUMENT_COMPLETED` | All recipients have completed their actions | | `DOCUMENT_REJECTED` | A recipient rejects the document | -| `DOCUMENT_CANCELLED` | The document owner deletes the document | +| `DOCUMENT_CANCELLED` | A pending document is explicitly cancelled or a document owner deletes it | +| `RECIPIENT_EXPIRED` | A recipient's signing deadline passes before they sign or reject | | `DOCUMENT_REMINDER_SENT` | A reminder email is sent to a recipient | | `TEMPLATE_CREATED` | A new template is created | | `TEMPLATE_UPDATED` | A template is modified | @@ -294,6 +304,7 @@ Each webhook call shows the following details: - Timestamp - Response code - Request and response bodies +- Response headers Click any call to see full details including headers and response data. @@ -318,17 +329,17 @@ Documenso will attempt to deliver the same payload again ## Retry Policy -When a webhook delivery fails (non-2xx response or timeout), Documenso automatically retries with exponential backoff: +A delivery fails when the endpoint returns a non-`2xx` response, the 10-second timeout expires, or the request fails. Redirects are not followed, so `3xx` responses also fail. Network and SSRF-blocked requests are recorded with response code `0`. -| Attempt | Delay | -| ------- | ----- | -| 1 | Immediate | -| 2 | 1 minute | -| 3 | 5 minutes | -| 4 | 30 minutes | -| 5 | 2 hours | +For self-hosted deployments, retries are handled by the background-job provider selected with `NEXT_PRIVATE_JOBS_PROVIDER`: -After 5 failed attempts, the webhook is marked as failed and no further automatic retries occur. You can manually resend failed webhooks from the dashboard. +| Provider | Total attempts | Retry timing | +| -------- | -------------- | ------------ | +| Local (default) | 4 | Back-to-back, with no backoff | +| BullMQ | 3 | Exponential backoff starting at 1 second | +| Inngest | 5 | Inngest platform backoff | + +Only the individual delivery (`WebhookCall`) record is marked as failed. Documenso does not automatically disable the webhook or apply a circuit breaker, so future matching events continue to be delivered. After automatic attempts are exhausted, you can manually resend a failed delivery from the dashboard. If your endpoint consistently fails, consider reviewing your server logs and ensuring your endpoint meets all [URL requirements](#webhook-url-requirements). diff --git a/apps/docs/content/docs/developers/webhooks/verification.mdx b/apps/docs/content/docs/developers/webhooks/verification.mdx index a6cac916d..751d30d89 100644 --- a/apps/docs/content/docs/developers/webhooks/verification.mdx +++ b/apps/docs/content/docs/developers/webhooks/verification.mdx @@ -255,6 +255,7 @@ const validEvents = [ 'DOCUMENT_REJECTED', 'DOCUMENT_CANCELLED', 'DOCUMENT_REMINDER_SENT', + 'RECIPIENT_EXPIRED', 'TEMPLATE_CREATED', 'TEMPLATE_UPDATED', 'TEMPLATE_DELETED', From b3c609a549c428c403366e7a2ae1e578a76670a0 Mon Sep 17 00:00:00 2001 From: Ephraim Duncan <55143799+ephraimduncan@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:55:43 +0000 Subject: [PATCH 13/27] feat: bulk download documents (#2711) --- .../envelopes-bulk-download-dialog.tsx | 377 ++++++++++++++++++ .../envelopes-table-bulk-action-bar.tsx | 102 ++++- .../t.$teamUrl+/documents._index.tsx | 91 ++++- .../t.$teamUrl+/templates._index.tsx | 12 +- package-lock.json | 13 +- package.json | 1 + .../api-access-file-download.spec.ts | 118 ++++++ .../documents/bulk-document-actions.spec.ts | 153 ++++++- .../templates/bulk-template-actions.spec.ts | 34 +- packages/app-tests/package.json | 4 +- packages/lib/client-only/create-zip-writer.ts | 178 +++++++++ packages/lib/client-only/download-pdf.ts | 25 +- packages/prisma/seed/documents.ts | 7 +- packages/ui/primitives/radio-group.tsx | 41 +- 14 files changed, 1091 insertions(+), 65 deletions(-) create mode 100644 apps/remix/app/components/dialogs/envelopes-bulk-download-dialog.tsx create mode 100644 packages/app-tests/e2e/api/v2/unauthorized-api-access/api-access-file-download.spec.ts create mode 100644 packages/lib/client-only/create-zip-writer.ts diff --git a/apps/remix/app/components/dialogs/envelopes-bulk-download-dialog.tsx b/apps/remix/app/components/dialogs/envelopes-bulk-download-dialog.tsx new file mode 100644 index 000000000..940055854 --- /dev/null +++ b/apps/remix/app/components/dialogs/envelopes-bulk-download-dialog.tsx @@ -0,0 +1,377 @@ +import { + createZipWriter, + sanitizeZipPathSegment, + type ZipFileEntry, +} from '@documenso/lib/client-only/create-zip-writer'; +import { downloadFile } from '@documenso/lib/client-only/download-file'; +import { fetchPDF } from '@documenso/lib/client-only/download-pdf'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; +import { RadioGroupSegmented, RadioGroupSegmentedItem } from '@documenso/ui/primitives/radio-group'; +import { useToast } from '@documenso/ui/primitives/use-toast'; +import { plural } from '@lingui/core/macro'; +import { Plural, Trans, useLingui } from '@lingui/react/macro'; +import { DocumentStatus } from '@prisma/client'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useEffect, useRef, useState } from 'react'; +import { match } from 'ts-pattern'; + +/** + * The maximum number of documents that can be downloaded in a single bulk + * download. Each document requires fetching its full PDFs into the browser, + * so this bounds both request volume and blob storage usage. Matches the + * spirit of the server-side 100 cap on bulk move/delete/cancel. + */ +export const MAX_BULK_DOWNLOAD_ENVELOPES = 50; + +type BulkDownloadVersion = 'signed' | 'original' | 'pending'; + +export type EnvelopeBulkDownloadItem = { + id: string; + title: string; + status: DocumentStatus; + + /** + * Whether the envelope is a legacy (v1) envelope. Legacy envelopes use a + * different field-rendering pipeline that the partial PDF helper does not + * implement, so the Partial option is hidden for them. + */ + isLegacy: boolean; +}; + +const getDefaultVersion = (envelope: EnvelopeBulkDownloadItem): BulkDownloadVersion => + envelope.status === DocumentStatus.COMPLETED ? 'signed' : 'original'; + +export type EnvelopesBulkDownloadDialogProps = { + envelopes: EnvelopeBulkDownloadItem[]; + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: (successfulEnvelopeIds: string[]) => void; +} & Omit; + +export const EnvelopesBulkDownloadDialog = ({ + envelopes, + open, + onOpenChange, + onSuccess, + ...props +}: EnvelopesBulkDownloadDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [versionMap, setVersionMap] = useState>({}); + const [progress, setProgress] = useState(0); + const [isDownloading, setIsDownloading] = useState(false); + + const abortRef = useRef(false); + + const trpcUtils = trpc.useUtils(); + + const isOverDownloadLimit = envelopes.length > MAX_BULK_DOWNLOAD_ENVELOPES; + + useEffect(() => { + if (!open) { + return; + } + + setVersionMap(Object.fromEntries(envelopes.map((envelope) => [envelope.id, getDefaultVersion(envelope)]))); + setProgress(0); + }, [open]); + + const getDownloadVersion = (envelope: EnvelopeBulkDownloadItem): BulkDownloadVersion => + versionMap[envelope.id] ?? getDefaultVersion(envelope); + + /** + * The version options selectable for an envelope, mirroring the gating used + * by the single envelope download dialog: + * - COMPLETED: signed or original. + * - PENDING (non-legacy): partial or original. Legacy envelopes use a + * field-rendering pipeline the partial PDF helper does not implement. + * - Anything else: original only, so no choice is shown. + */ + const getVersionOptions = ( + envelope: EnvelopeBulkDownloadItem, + ): { value: BulkDownloadVersion; label: string }[] | null => { + if (envelope.status === DocumentStatus.COMPLETED) { + return [ + { value: 'signed', label: t({ message: 'Signed', context: 'Signed document (adjective)' }) }, + { value: 'original', label: t({ message: 'Original', context: 'Original document (adjective)' }) }, + ]; + } + + if (envelope.status === DocumentStatus.PENDING && !envelope.isLegacy) { + return [ + { value: 'pending', label: t({ message: 'Partial', context: 'Partially signed document (adjective)' }) }, + { value: 'original', label: t({ message: 'Original', context: 'Original document (adjective)' }) }, + ]; + } + + return null; + }; + + const getStatusLabel = (status: DocumentStatus) => + match(status) + .with(DocumentStatus.COMPLETED, () => t`Completed`) + .with(DocumentStatus.PENDING, () => t`Pending`) + .with(DocumentStatus.DRAFT, () => t`Draft`) + .with(DocumentStatus.REJECTED, () => t`Rejected`) + .with(DocumentStatus.CANCELLED, () => t`Cancelled`) + .exhaustive(); + + const onDownload = async () => { + if (envelopes.length === 0 || isOverDownloadLimit || isDownloading) { + return; + } + + abortRef.current = false; + setIsDownloading(true); + setProgress(0); + + const zipWriter = createZipWriter(); + + const successfulEnvelopeIds: string[] = []; + let failedDownloads = 0; + + try { + for (const envelope of envelopes) { + if (abortRef.current) { + break; + } + + try { + const downloadVersion = getDownloadVersion(envelope); + + const { data: envelopeItems } = await trpcUtils.envelope.item.getManyByToken.fetch({ + envelopeId: envelope.id, + access: { + type: 'user', + }, + }); + + // Each envelope's items are grouped in their own folder. The id + // prefix guarantees uniqueness, the truncated title keeps it + // readable without risking overly long extraction paths. + const folderName = sanitizeZipPathSegment(`${envelope.id}_${envelope.title}`.slice(0, 96)); + + // Buffer this envelope's files before writing so a failed envelope + // is either fully in the zip or not at all. Files from previous + // envelopes have already been written to the zip stream and freed. + const envelopeFiles: ZipFileEntry[] = []; + + for (const envelopeItem of envelopeItems) { + const { filename, blob } = await fetchPDF({ + envelopeItem, + token: undefined, + fileName: envelopeItem.title, + version: downloadVersion, + }); + + envelopeFiles.push({ + filename: `${folderName}/${sanitizeZipPathSegment(filename)}`, + data: blob, + }); + } + + for (const file of envelopeFiles) { + await zipWriter.addFile(file); + } + + successfulEnvelopeIds.push(envelope.id); + } catch (error) { + console.error(error); + failedDownloads++; + } + + setProgress((p) => p + 1); + } + + // The user intentionally stopped the download, discard anything fetched + // so far without toasting an error. + if (abortRef.current) { + zipWriter.abort(); + return; + } + + if (successfulEnvelopeIds.length === 0) { + zipWriter.abort(); + + toast({ + title: t`Error`, + description: t`An error occurred while downloading the documents.`, + variant: 'destructive', + }); + return; + } + + try { + downloadFile({ + filename: `documenso-documents-${new Date().toISOString().slice(0, 10)}.zip`, + data: zipWriter.finalize(), + }); + } catch (error) { + console.error(error); + + zipWriter.abort(); + + toast({ + title: t`Error`, + description: t`An error occurred while downloading the documents.`, + variant: 'destructive', + }); + + return; + } + + if (failedDownloads > 0) { + toast({ + title: t`Documents partially downloaded`, + description: t`${plural(successfulEnvelopeIds.length, { + one: '# document downloaded.', + other: '# documents downloaded.', + })} ${plural(failedDownloads, { + one: '# document could not be downloaded.', + other: '# documents could not be downloaded.', + })}`, + variant: 'destructive', + }); + onSuccess?.(successfulEnvelopeIds); + return; + } + + toast({ + title: t`Documents downloaded`, + description: plural(successfulEnvelopeIds.length, { + one: '# document has been downloaded.', + other: '# documents have been downloaded.', + }), + }); + + onSuccess?.(successfulEnvelopeIds); + onOpenChange(false); + } finally { + setIsDownloading(false); + } + }; + + return ( + { + if (!isDownloading) { + onOpenChange(value); + } + }} + > + + + + Download Documents + + + + + + + + {isOverDownloadLimit && ( + + + + You can download up to {MAX_BULK_DOWNLOAD_ENVELOPES} documents at a time. Deselect some documents to + continue. + + + + )} + +
+
+
+ {envelopes.map((envelope) => { + const versionOptions = getVersionOptions(envelope); + + return ( +
+
+

+ {envelope.title} +

+

{getStatusLabel(envelope.status)}

+
+ + {versionOptions && ( + + setVersionMap((prev) => ({ + ...prev, + [envelope.id]: value as BulkDownloadVersion, + })) + } + aria-label={t`Download version for ${envelope.title}`} + > + {versionOptions.map((option) => ( + + {option.label} + + ))} + + )} +
+ ); + })} +
+
+ + {isDownloading && ( +

+ + Downloading {progress} / {envelopes.length}... + +

+ )} + + + + + + +
+
+
+ ); +}; diff --git a/apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx b/apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx index ccba956f6..e9de26777 100644 --- a/apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx +++ b/apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx @@ -1,9 +1,11 @@ import { Button } from '@documenso/ui/primitives/button'; import { Trans, useLingui } from '@lingui/react/macro'; -import { FolderInputIcon, Trash2Icon, XCircleIcon, XIcon } from 'lucide-react'; +import { DownloadIcon, FolderInputIcon, Trash2Icon, XCircleIcon, XIcon } from 'lucide-react'; +import { useEffect } from 'react'; export type EnvelopesTableBulkActionBarProps = { selectedCount: number; + onDownloadClick?: () => void; onMoveClick: () => void; onDeleteClick: () => void; onCancelClick?: () => void; @@ -12,6 +14,7 @@ export type EnvelopesTableBulkActionBarProps = { export const EnvelopesTableBulkActionBar = ({ selectedCount, + onDownloadClick, onMoveClick, onDeleteClick, onCancelClick, @@ -19,37 +22,106 @@ export const EnvelopesTableBulkActionBar = ({ }: EnvelopesTableBulkActionBarProps) => { const { t } = useLingui(); + useEffect(() => { + if (selectedCount === 0) { + return; + } + + const onKeyDown = (event: KeyboardEvent) => { + // Radix dismissable layers (dialogs, dropdowns, etc) call preventDefault + // when handling Escape, so this only clears the selection when nothing + // else consumed the key press. + if (event.key === 'Escape' && !event.defaultPrevented) { + onClearSelection(); + } + }; + + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [selectedCount, onClearSelection]); + if (selectedCount === 0) { return null; } return ( -
- - {selectedCount} selected - +
+
+ + {selectedCount} selected + + + +
-
+
- + {onDownloadClick && ( + + )} + {onCancelClick && ( - )} - -
); diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx index 3350e29e1..37883b796 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx @@ -14,13 +14,22 @@ import type { RowSelectionState } from '@documenso/ui/primitives/data-table'; import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; import { msg } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; -import { EnvelopeType, FolderType, OrganisationType } from '@prisma/client'; +import { + EnvelopeType, + FolderType, + OrganisationType, + type DocumentStatus as PrismaDocumentStatus, +} from '@prisma/client'; import { useEffect, useMemo, useState } from 'react'; import { Link, useNavigate, useParams, useSearchParams } from 'react-router'; import { z } from 'zod'; import { EnvelopesBulkCancelDialog } from '~/components/dialogs/envelopes-bulk-cancel-dialog'; import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog'; +import { + type EnvelopeBulkDownloadItem, + EnvelopesBulkDownloadDialog, +} from '~/components/dialogs/envelopes-bulk-download-dialog'; import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog'; import { DocumentSearch } from '~/components/general/document/document-search'; import { DocumentStatus } from '~/components/general/document/document-status'; @@ -38,6 +47,14 @@ export function meta() { return appMetaTags(msg`Documents`); } +type EnvelopeMetaCache = Record; + +// Stable initial values: `useSessionStorage` keeps its setter identity stable +// only while the initial value reference is stable, and the metadata cache +// effect below depends on that setter. +const EMPTY_ROW_SELECTION: RowSelectionState = {}; +const EMPTY_ENVELOPE_META_CACHE: EnvelopeMetaCache = {}; + const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({ status: true, period: true, @@ -61,9 +78,18 @@ export default function DocumentsPage() { const [isMovingDocument, setIsMovingDocument] = useState(false); const [documentToMove, setDocumentToMove] = useState(null); - const [rowSelection, setRowSelection] = useSessionStorage('documents-bulk-selection', {}); + // Scoped by team so selections made in one team never leak into another. + const [rowSelection, setRowSelection] = useSessionStorage( + `documents-bulk-selection-${team.id}`, + EMPTY_ROW_SELECTION, + ); + const [envelopeMetaCache, setEnvelopeMetaCache] = useSessionStorage( + `documents-bulk-selection-meta-${team.id}`, + EMPTY_ENVELOPE_META_CACHE, + ); const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false); const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); + const [isBulkDownloadDialogOpen, setIsBulkDownloadDialogOpen] = useState(false); const [isBulkCancelDialogOpen, setIsBulkCancelDialogOpen] = useState(false); const selectedEnvelopeIds = useMemo(() => { @@ -96,6 +122,51 @@ export default function DocumentsPage() { }, ); + useEffect(() => { + setEnvelopeMetaCache((prev) => { + const next: EnvelopeMetaCache = {}; + + for (const id of Object.keys(prev)) { + if (rowSelection[id]) { + next[id] = prev[id]; + } + } + + for (const document of data?.data ?? []) { + if (rowSelection[document.envelopeId]) { + next[document.envelopeId] = { + title: document.title, + status: document.status, + isLegacy: document.internalVersion === 1, + }; + } + } + + return next; + }); + }, [data?.data, rowSelection, setEnvelopeMetaCache]); + + const selectedEnvelopesForDownload = useMemo(() => { + return selectedEnvelopeIds + .map((id): EnvelopeBulkDownloadItem | null => { + const meta = envelopeMetaCache[id]; + + if (!meta) { + return null; + } + + return { + id, + title: meta.title, + status: meta.status, + // Stale cache entries predating this field are treated as legacy so + // the Partial option is never offered without certainty. + isLegacy: meta.isLegacy ?? true, + }; + }) + .filter((item): item is EnvelopeBulkDownloadItem => item !== null); + }, [selectedEnvelopeIds, envelopeMetaCache]); + const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => { const params = new URLSearchParams(searchParams); @@ -238,12 +309,28 @@ export default function DocumentsPage() { setIsBulkDownloadDialogOpen(true)} onMoveClick={() => setIsBulkMoveDialogOpen(true)} onDeleteClick={() => setIsBulkDeleteDialogOpen(true)} onCancelClick={() => setIsBulkCancelDialogOpen(true)} onClearSelection={() => setRowSelection({})} /> + { + setRowSelection((prev) => { + const next = { ...prev }; + for (const id of successfulEnvelopeIds) { + delete next[id]; + } + return next; + }); + }} + /> + ('templates-bulk-selection', {}); + // Scoped by team so selections made in one team never leak into another. + const [rowSelection, setRowSelection] = useSessionStorage( + `templates-bulk-selection-${team.id}`, + EMPTY_ROW_SELECTION, + ); const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false); const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); diff --git a/package-lock.json b/package-lock.json index 2f7ef057b..3510fd7a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@prisma/extension-read-replicas": "^0.4.1", "ai": "^5.0.104", "cron-parser": "^5.5.0", + "fflate": "^0.8.3", "luxon": "^3.7.2", "patch-package": "^8.0.1", "posthog-node": "4.18.0", @@ -20080,9 +20081,9 @@ } }, "node_modules/fflate": { - "version": "0.4.8", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", - "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==", + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", "license": "MIT" }, "node_modules/file-selector": { @@ -26685,6 +26686,12 @@ "web-vitals": "^4.2.4" } }, + "node_modules/posthog-js/node_modules/fflate": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz", + "integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==", + "license": "MIT" + }, "node_modules/posthog-node": { "version": "4.18.0", "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-4.18.0.tgz", diff --git a/package.json b/package.json index 97b1b0811..60e97f1c8 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "@prisma/extension-read-replicas": "^0.4.1", "ai": "^5.0.104", "cron-parser": "^5.5.0", + "fflate": "^0.8.3", "luxon": "^3.7.2", "patch-package": "^8.0.1", "posthog-node": "4.18.0", diff --git a/packages/app-tests/e2e/api/v2/unauthorized-api-access/api-access-file-download.spec.ts b/packages/app-tests/e2e/api/v2/unauthorized-api-access/api-access-file-download.spec.ts new file mode 100644 index 000000000..ff9b7ec1a --- /dev/null +++ b/packages/app-tests/e2e/api/v2/unauthorized-api-access/api-access-file-download.spec.ts @@ -0,0 +1,118 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; +import { expect, test } from '@playwright/test'; + +import { apiSignin } from '../../../fixtures/authentication'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); + +test.describe.configure({ + mode: 'parallel', +}); + +const downloadUrl = (envelopeId: string, envelopeItemId: string, version: 'original' | 'signed' | 'pending') => + `${WEBAPP_BASE_URL}/api/files/envelope/${envelopeId}/envelopeItem/${envelopeItemId}/download/${version}`; + +const seedOwnerWithDraft = async () => { + const owner = await seedUser(); + + const draft = await seedDraftDocument(owner.user, owner.team.id, [], { + createDocumentOptions: { title: 'File Download Auth Test' }, + }); + + return { owner, draft, draftItem: draft.envelopeItems[0] }; +}; + +test.describe('Envelope item file download endpoint authorization', () => { + test('rejects an unauthenticated download request', async ({ request }) => { + const { draft, draftItem } = await seedOwnerWithDraft(); + + const res = await request.get(downloadUrl(draft.id, draftItem.id, 'original')); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(401); + }); + + test('rejects a download request from a user outside the organisation', async ({ page }) => { + const { draft, draftItem } = await seedOwnerWithDraft(); + const { user: outsider } = await seedUser(); + + await apiSignin({ page, email: outsider.email }); + + const res = await page.request.get(downloadUrl(draft.id, draftItem.id, 'original')); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(403); + }); + + test('returns 404 for a nonexistent envelope', async ({ page }) => { + const { user } = await seedUser(); + + await apiSignin({ page, email: user.email }); + + const res = await page.request.get( + downloadUrl('envelope_does_not_exist', 'envelope_item_does_not_exist', 'original'), + ); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('rejects a pending version download for a draft envelope', async ({ page }) => { + const { owner, draft, draftItem } = await seedOwnerWithDraft(); + + await apiSignin({ page, email: owner.user.email }); + + const res = await page.request.get(downloadUrl(draft.id, draftItem.id, 'pending')); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(400); + }); + + test('rejects a pending version download for a legacy envelope', async ({ page }) => { + const owner = await seedUser(); + const { user: recipient } = await seedUser(); + + // Default internalVersion is 1 (legacy). + const pendingDocument = await seedPendingDocument(owner.user, owner.team.id, [recipient], { + createDocumentOptions: { title: 'Legacy Pending Download Test' }, + }); + + const envelopeItem = pendingDocument.envelopeItems[0]; + + await apiSignin({ page, email: owner.user.email }); + + const res = await page.request.get(downloadUrl(pendingDocument.id, envelopeItem.id, 'pending')); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(400); + }); + + test('allows the owner to download their own document', async ({ page }) => { + const { owner, draft, draftItem } = await seedOwnerWithDraft(); + + await apiSignin({ page, email: owner.user.email }); + + const res = await page.request.get(downloadUrl(draft.id, draftItem.id, 'original')); + + expect(res.ok()).toBeTruthy(); + expect(res.headers()['content-type']).toContain('application/pdf'); + + const body = await res.body(); + + // %PDF magic bytes. + expect(Array.from(body.subarray(0, 4))).toEqual([0x25, 0x50, 0x44, 0x46]); + }); + + test('rejects a recipient-token download with an invalid token', async ({ request }) => { + const { draftItem } = await seedOwnerWithDraft(); + + const res = await request.get( + `${WEBAPP_BASE_URL}/api/files/token/invalid-token-12345/envelopeItem/${draftItem.id}/download/original`, + ); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); +}); diff --git a/packages/app-tests/e2e/documents/bulk-document-actions.spec.ts b/packages/app-tests/e2e/documents/bulk-document-actions.spec.ts index 5fc563041..12282ca15 100644 --- a/packages/app-tests/e2e/documents/bulk-document-actions.spec.ts +++ b/packages/app-tests/e2e/documents/bulk-document-actions.spec.ts @@ -1,3 +1,5 @@ +import fs from 'node:fs'; +import { createTeam } from '@documenso/lib/server-only/team/create-team'; import { prisma } from '@documenso/prisma'; import { seedCompletedDocument, seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents'; import { seedBlankFolder } from '@documenso/prisma/seed/folders'; @@ -5,6 +7,7 @@ import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams'; import { seedUser } from '@documenso/prisma/seed/users'; import { expect, test } from '@playwright/test'; import { DocumentStatus, TeamMemberRole } from '@prisma/client'; +import { unzipSync } from 'fflate'; import { apiSignin, apiSignout } from '../fixtures/authentication'; import { expectToastTextToBeVisible } from '../fixtures/generic'; @@ -50,10 +53,10 @@ test('[BULK_ACTIONS]: can select multiple documents with checkboxes', async ({ p }); await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click(); - await expect(page.getByText('1 selected')).toBeVisible(); + await expect(page.getByText(/1\s*selected/)).toBeVisible(); await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click(); - await expect(page.getByText('2 selected')).toBeVisible(); + await expect(page.getByText(/2\s*selected/)).toBeVisible(); }); test('[BULK_ACTIONS]: header checkbox selects all documents on page', async ({ page }) => { @@ -67,7 +70,7 @@ test('[BULK_ACTIONS]: header checkbox selects all documents on page', async ({ p await page.locator('thead').getByRole('checkbox').click(); - await expect(page.getByText(`${documents.length} selected`)).toBeVisible(); + await expect(page.getByText(new RegExp(`${documents.length}\\s*selected`))).toBeVisible(); }); test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => { @@ -80,11 +83,11 @@ test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => { }); await page.locator('thead').getByRole('checkbox').click(); - await expect(page.getByText(/\d+ selected/)).toBeVisible(); + await expect(page.getByText(/\d+\s*selected/)).toBeVisible(); await page.getByLabel('Clear selection').click(); - await expect(page.getByText(/\d+ selected/)).not.toBeVisible(); + await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible(); }); test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page }) => { @@ -98,13 +101,13 @@ test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page }) await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click(); await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click(); - await page.getByRole('button', { name: 'Move to Folder' }).click(); + await page.getByRole('button', { name: 'Move', exact: true }).click(); await expect(page.getByRole('dialog')).toBeVisible(); await expect(page.getByText('Move Documents to Folder')).toBeVisible(); await page.getByRole('button', { name: folder.name }).click(); - await page.getByRole('button', { name: 'Move' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click(); await expectToastTextToBeVisible(page, 'Selected items have been moved.'); @@ -113,6 +116,122 @@ test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page }) await expect(page.getByRole('link', { name: 'Bulk Test Doc 2' })).toBeVisible(); }); +test('[BULK_ACTIONS]: selection does not leak between teams', async ({ page }) => { + const { sender } = await seedBulkActionsTestRequirements(); + + const teamBUrl = `team-b-${Date.now()}`; + + await createTeam({ + userId: sender.user.id, + teamName: 'Team B', + teamUrl: teamBUrl, + organisationId: sender.organisation.id, + inheritMembers: true, + }); + + const teamB = await prisma.team.findFirstOrThrow({ + where: { url: teamBUrl }, + }); + + await seedDraftDocument(sender.user, teamB.id, [], { + createDocumentOptions: { title: 'Team B Doc' }, + }); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/documents`, + }); + + await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click(); + await expect(page.getByText(/1\s*selected/)).toBeVisible(); + + // The selection made in team A must not appear in team B. + await page.goto(`/t/${teamBUrl}/documents`); + await expect(page.getByRole('link', { name: 'Team B Doc' })).toBeVisible(); + await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible(); + + // Returning to team A restores its selection. + await page.goto(`/t/${sender.team.url}/documents`); + await expect(page.getByText(/1\s*selected/)).toBeVisible(); +}); + +test('[BULK_ACTIONS]: escape clears selection unless a dialog is open', async ({ page }) => { + const { sender } = await seedBulkActionsTestRequirements(); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/documents`, + }); + + await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click(); + await expect(page.getByText(/1\s*selected/)).toBeVisible(); + + // Escape while a dialog is open should close the dialog but keep the selection. + await page.getByRole('button', { name: 'Move', exact: true }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + await page.keyboard.press('Escape'); + + await expect(page.getByRole('dialog')).not.toBeVisible(); + await expect(page.getByText(/1\s*selected/)).toBeVisible(); + + // Escape with no dialog open should clear the selection. + await page.keyboard.press('Escape'); + + await expect(page.getByText(/1\s*selected/)).not.toBeVisible(); +}); + +test('[BULK_ACTIONS]: can bulk download multiple documents as a zip', async ({ page }) => { + const { sender, documents } = await seedBulkActionsTestRequirements(); + + const [doc1, doc2] = documents; + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/documents`, + }); + + await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click(); + await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click(); + + await page.getByRole('button', { name: 'Download', exact: true }).click(); + + const dialog = page.getByRole('dialog'); + + await expect(dialog).toBeVisible(); + await expect(dialog.getByText('Download Documents')).toBeVisible(); + await expect(dialog.getByText('Bulk Test Doc 1')).toBeVisible(); + await expect(dialog.getByText('Bulk Test Doc 2')).toBeVisible(); + await expect(dialog.getByText('Draft').first()).toBeVisible(); + + const downloadPromise = page.waitForEvent('download', { timeout: 10_000 }); + + await dialog.getByRole('button', { name: 'Download' }).click(); + + const download = await downloadPromise; + + expect(download.suggestedFilename()).toMatch(/^documenso-documents-\d{4}-\d{2}-\d{2}\.zip$/); + + const downloadPath = await download.path(); + const zipContents = unzipSync(new Uint8Array(fs.readFileSync(downloadPath))); + + // Each envelope's files are nested inside an `envelopeId_title` folder. + expect(Object.keys(zipContents).sort()).toEqual( + [`${doc1.id}_Bulk Test Doc 1/Bulk Test Doc 1.pdf`, `${doc2.id}_Bulk Test Doc 2/Bulk Test Doc 2.pdf`].sort(), + ); + + // Each entry should be a valid non-empty PDF (%PDF magic bytes). + for (const entry of Object.values(zipContents)) { + expect(Array.from(entry.slice(0, 4))).toEqual([0x25, 0x50, 0x44, 0x46]); + } + + await expectToastTextToBeVisible(page, 'Documents downloaded'); + await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible(); +}); + test('[BULK_ACTIONS]: can delete multiple draft documents', async ({ page }) => { const { sender } = await seedBulkActionsTestRequirements(); @@ -152,14 +271,14 @@ test('[BULK_ACTIONS]: selection clears after successful move', async ({ page }) }); await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click(); - await expect(page.getByText('1 selected')).toBeVisible(); + await expect(page.getByText(/1\s*selected/)).toBeVisible(); - await page.getByRole('button', { name: 'Move to Folder' }).click(); + await page.getByRole('button', { name: 'Move', exact: true }).click(); await page.getByRole('button', { name: folder.name }).click(); - await page.getByRole('button', { name: 'Move' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click(); await expectToastTextToBeVisible(page, 'Selected items have been moved.'); - await expect(page.getByText(/\d+ selected/)).not.toBeVisible(); + await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible(); }); test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => { @@ -172,13 +291,13 @@ test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page } }); await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click(); - await expect(page.getByText('1 selected')).toBeVisible(); + await expect(page.getByText(/1\s*selected/)).toBeVisible(); await page.getByRole('button', { name: 'Delete' }).click(); await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click(); await expectToastTextToBeVisible(page, 'Documents deleted'); - await expect(page.getByText(/\d+ selected/)).not.toBeVisible(); + await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible(); }); test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => { @@ -199,7 +318,7 @@ test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) = await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click(); - await page.getByRole('button', { name: 'Move to Folder' }).click(); + await page.getByRole('button', { name: 'Move', exact: true }).click(); await expect(page.getByRole('dialog')).toBeVisible(); await expect(page.getByRole('button', { name: folder.name })).toBeVisible(); @@ -236,14 +355,14 @@ test('[BULK_ACTIONS]: can move documents from folder to home (root)', async ({ p await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible(); await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click(); - await expect(page.getByText('1 selected')).toBeVisible(); + await expect(page.getByText(/1\s*selected/)).toBeVisible(); - await page.getByRole('button', { name: 'Move to Folder' }).click(); + await page.getByRole('button', { name: 'Move', exact: true }).click(); await expect(page.getByRole('dialog')).toBeVisible(); await page.getByRole('button', { name: 'Home (No Folder)' }).click(); - await page.getByRole('button', { name: 'Move' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click(); await expectToastTextToBeVisible(page, 'Selected items have been moved.'); diff --git a/packages/app-tests/e2e/templates/bulk-template-actions.spec.ts b/packages/app-tests/e2e/templates/bulk-template-actions.spec.ts index 4ef72f495..d6edfa24b 100644 --- a/packages/app-tests/e2e/templates/bulk-template-actions.spec.ts +++ b/packages/app-tests/e2e/templates/bulk-template-actions.spec.ts @@ -49,10 +49,10 @@ test('[BULK_ACTIONS]: can select multiple templates with checkboxes', async ({ p }); await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click(); - await expect(page.getByText('1 selected')).toBeVisible(); + await expect(page.getByText(/1\s*selected/)).toBeVisible(); await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click(); - await expect(page.getByText('2 selected')).toBeVisible(); + await expect(page.getByText(/2\s*selected/)).toBeVisible(); }); test('[BULK_ACTIONS]: header checkbox selects all templates on page', async ({ page }) => { @@ -66,7 +66,7 @@ test('[BULK_ACTIONS]: header checkbox selects all templates on page', async ({ p await page.locator('thead').getByRole('checkbox').click(); - await expect(page.getByText(`${templates.length} selected`)).toBeVisible(); + await expect(page.getByText(new RegExp(`${templates.length}\\s*selected`))).toBeVisible(); }); test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => { @@ -79,11 +79,11 @@ test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => { }); await page.locator('thead').getByRole('checkbox').click(); - await expect(page.getByText(/\d+ selected/)).toBeVisible(); + await expect(page.getByText(/\d+\s*selected/)).toBeVisible(); await page.getByLabel('Clear selection').click(); - await expect(page.getByText(/\d+ selected/)).not.toBeVisible(); + await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible(); }); test('[BULK_ACTIONS]: can move multiple templates to a folder', async ({ page }) => { @@ -97,13 +97,13 @@ test('[BULK_ACTIONS]: can move multiple templates to a folder', async ({ page }) await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click(); await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click(); - await page.getByRole('button', { name: 'Move to Folder' }).click(); + await page.getByRole('button', { name: 'Move', exact: true }).click(); await expect(page.getByRole('dialog')).toBeVisible(); await expect(page.getByText('Move Templates to Folder')).toBeVisible(); await page.getByRole('button', { name: folder.name }).click(); - await page.getByRole('button', { name: 'Move' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click(); await expectToastTextToBeVisible(page, 'Selected items have been moved.'); @@ -151,14 +151,14 @@ test('[BULK_ACTIONS]: selection clears after successful move', async ({ page }) }); await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click(); - await expect(page.getByText('1 selected')).toBeVisible(); + await expect(page.getByText(/1\s*selected/)).toBeVisible(); - await page.getByRole('button', { name: 'Move to Folder' }).click(); + await page.getByRole('button', { name: 'Move', exact: true }).click(); await page.getByRole('button', { name: folder.name }).click(); - await page.getByRole('button', { name: 'Move' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click(); await expectToastTextToBeVisible(page, 'Selected items have been moved.'); - await expect(page.getByText(/\d+ selected/)).not.toBeVisible(); + await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible(); }); test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => { @@ -171,13 +171,13 @@ test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page } }); await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click(); - await expect(page.getByText('1 selected')).toBeVisible(); + await expect(page.getByText(/1\s*selected/)).toBeVisible(); await page.getByRole('button', { name: 'Delete' }).click(); await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click(); await expectToastTextToBeVisible(page, 'Templates deleted'); - await expect(page.getByText(/\d+ selected/)).not.toBeVisible(); + await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible(); }); test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => { @@ -199,7 +199,7 @@ test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) = await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click(); - await page.getByRole('button', { name: 'Move to Folder' }).click(); + await page.getByRole('button', { name: 'Move', exact: true }).click(); await expect(page.getByRole('dialog')).toBeVisible(); await expect(page.getByRole('button', { name: folder.name })).toBeVisible(); @@ -236,14 +236,14 @@ test('[BULK_ACTIONS]: can move templates from folder to home (root)', async ({ p await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible(); await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click(); - await expect(page.getByText('1 selected')).toBeVisible(); + await expect(page.getByText(/1\s*selected/)).toBeVisible(); - await page.getByRole('button', { name: 'Move to Folder' }).click(); + await page.getByRole('button', { name: 'Move', exact: true }).click(); await expect(page.getByRole('dialog')).toBeVisible(); await page.getByRole('button', { name: 'Home (No Folder)' }).click(); - await page.getByRole('button', { name: 'Move' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click(); await expectToastTextToBeVisible(page, 'Selected items have been moved.'); diff --git a/packages/app-tests/package.json b/packages/app-tests/package.json index 7b552080c..245619141 100644 --- a/packages/app-tests/package.json +++ b/packages/app-tests/package.json @@ -18,9 +18,9 @@ "@playwright/test": "1.56.1", "@types/node": "^20", "@types/pngjs": "^6.0.5", - "tsx": "^4.23.1", "pixelmatch": "^7.1.0", - "pngjs": "^7.0.0" + "pngjs": "^7.0.0", + "tsx": "^4.23.1" }, "dependencies": { "start-server-and-test": "^2.1.3" diff --git a/packages/lib/client-only/create-zip-writer.ts b/packages/lib/client-only/create-zip-writer.ts new file mode 100644 index 000000000..da8e07d97 --- /dev/null +++ b/packages/lib/client-only/create-zip-writer.ts @@ -0,0 +1,178 @@ +import { Zip, ZipPassThrough } from 'fflate'; + +export type ZipFileEntry = { + /** + * The path of the file within the archive. Forward slashes create folders. + * Individual path segments should be sanitized with + * {@link sanitizeZipPathSegment} when derived from user-controlled values. + */ + filename: string; + data: Blob; +}; + +/** + * Sanitizes a single path segment (folder or file name) for use inside a zip + * archive, replacing characters that are path separators or invalid on + * Windows extraction. + */ +export const sanitizeZipPathSegment = (segment: string): string => { + const sanitized = segment + .replace(/[\\/:*?"<>|\p{Cc}]/gu, '-') + .trim() + // Windows cannot extract folders or files ending with a dot. + .replace(/\.+$/, ''); + + return sanitized || 'untitled'; +}; + +export type ZipWriter = { + /** + * Adds a file to the zip stream. Files are written incrementally so the + * input blob can be garbage collected once this resolves. + */ + addFile: (entry: ZipFileEntry) => Promise; + + /** + * Finishes the zip stream and returns the archive as a blob. + */ + finalize: () => Blob; + + /** + * Discards the zip stream and any buffered output. + */ + abort: () => void; +}; + +/** + * How many bytes of a blob to materialise into the JS heap per read. Blobs + * (e.g. fetch responses) can be disk-backed by the browser, it is only + * `arrayBuffer()` that forces them into memory, so we read in slices. + */ +const READ_SLICE_BYTES = 4 * 1024 * 1024; + +/** + * Once this many bytes of zip output have accumulated in the JS heap they are + * coalesced into an intermediate blob. Browsers can page blob storage to disk + * under memory pressure, and the final `new Blob(parts)` composes parts by + * reference, so this keeps the heap bounded regardless of archive size. + */ +const OUTPUT_COALESCE_BYTES = 16 * 1024 * 1024; + +/** + * Creates an incremental client-side zip writer. + * + * Files are stored without compression (PDFs are already internally + * compressed) and streamed through the archive as they are added, so peak JS + * heap usage is bounded by roughly one read slice plus one output buffer + * rather than the total size of the archive. + */ +export const createZipWriter = (): ZipWriter => { + const usedNames = new Set(); + + const outputParts: Blob[] = []; + let pendingChunks: Uint8Array[] = []; + let pendingSize = 0; + + let zipError: Error | null = null; + + const flushPendingChunks = () => { + if (pendingChunks.length === 0) { + return; + } + + outputParts.push(new Blob(pendingChunks)); + pendingChunks = []; + pendingSize = 0; + }; + + // ZipPassThrough is synchronous (no workers), so output callbacks have + // always fired by the time `push`/`end` return. + const zipStream = new Zip((error, chunk, isFinal) => { + if (error) { + zipError = error; + return; + } + + pendingChunks.push(chunk); + pendingSize += chunk.length; + + if (pendingSize >= OUTPUT_COALESCE_BYTES || isFinal) { + flushPendingChunks(); + } + }); + + /** + * Deduplicates filenames case-insensitively (Windows extraction is + * case-insensitive) by appending " (n)" before the extension. + */ + const deduplicateFilename = (filename: string) => { + const match = filename.match(/^(.*?)(\.[^./]+)?$/); + + const baseName = match?.[1] ?? filename; + const extension = match?.[2] ?? ''; + + let candidate = filename; + let counter = 1; + + while (usedNames.has(candidate.toLowerCase())) { + candidate = `${baseName} (${counter})${extension}`; + counter += 1; + } + + usedNames.add(candidate.toLowerCase()); + + return candidate; + }; + + const addFile = async ({ filename, data }: ZipFileEntry) => { + if (zipError) { + throw zipError; + } + + const file = new ZipPassThrough(deduplicateFilename(filename)); + + zipStream.add(file); + + for (let offset = 0; offset < data.size; offset += READ_SLICE_BYTES) { + const slice = data.slice(offset, offset + READ_SLICE_BYTES); + + file.push(new Uint8Array(await slice.arrayBuffer())); + + if (zipError) { + throw zipError; + } + } + + file.push(new Uint8Array(0), true); + + if (zipError) { + throw zipError; + } + }; + + const finalize = () => { + zipStream.end(); + + if (zipError) { + throw zipError; + } + + flushPendingChunks(); + + return new Blob(outputParts, { type: 'application/zip' }); + }; + + const abort = () => { + zipStream.terminate(); + + pendingChunks = []; + pendingSize = 0; + outputParts.length = 0; + }; + + return { + addFile, + finalize, + abort, + }; +}; diff --git a/packages/lib/client-only/download-pdf.ts b/packages/lib/client-only/download-pdf.ts index ab5820cce..3bde40884 100644 --- a/packages/lib/client-only/download-pdf.ts +++ b/packages/lib/client-only/download-pdf.ts @@ -32,7 +32,11 @@ const versionToFilenameSuffix = (version: DocumentVersion): string => { } }; -export const downloadPDF = async ({ envelopeItem, token, fileName, version = 'signed' }: DownloadPDFProps) => { +/** + * Fetches a PDF for an envelope item and returns it as a blob alongside the + * filename it should be saved as. Throws on non-OK responses. + */ +export const fetchPDF = async ({ envelopeItem, token, fileName, version = 'signed' }: DownloadPDFProps) => { const downloadUrl = getEnvelopeItemPdfUrl({ type: 'download', envelopeItem: envelopeItem, @@ -40,12 +44,27 @@ export const downloadPDF = async ({ envelopeItem, token, fileName, version = 'si version, }); - const blob = await fetch(downloadUrl).then(async (res) => await res.blob()); + const response = await fetch(downloadUrl); + + if (!response.ok) { + throw new Error(`Failed to download PDF: ${response.status}`); + } + + const blob = await response.blob(); const baseTitle = (fileName ?? 'document').replace(/\.pdf$/, ''); - downloadFile({ + return { filename: `${baseTitle}${versionToFilenameSuffix(version)}`, + blob, + }; +}; + +export const downloadPDF = async (options: DownloadPDFProps) => { + const { filename, blob } = await fetchPDF(options); + + downloadFile({ + filename, data: blob, }); }; diff --git a/packages/prisma/seed/documents.ts b/packages/prisma/seed/documents.ts index 4bd357eae..f3616fac8 100644 --- a/packages/prisma/seed/documents.ts +++ b/packages/prisma/seed/documents.ts @@ -310,6 +310,9 @@ export const seedDraftDocument = async ( const documentId = await incrementDocumentId(); + const envelopeTitle = + typeof createDocumentOptions.title === 'string' ? createDocumentOptions.title : `[TEST] Document ${key} - Draft`; + const document = await prisma.envelope.create({ data: { id: prefixedId('envelope'), @@ -320,12 +323,12 @@ export const seedDraftDocument = async ( documentMetaId: documentMeta.id, source: DocumentSource.DOCUMENT, teamId, - title: `[TEST] Document ${key} - Draft`, + title: envelopeTitle, status: DocumentStatus.DRAFT, envelopeItems: { create: { id: prefixedId('envelope_item'), - title: `[TEST] Document ${key} - Draft`, + title: envelopeTitle, documentDataId: documentData.id, order: 1, }, diff --git a/packages/ui/primitives/radio-group.tsx b/packages/ui/primitives/radio-group.tsx index 1ce57a1e1..d89470755 100644 --- a/packages/ui/primitives/radio-group.tsx +++ b/packages/ui/primitives/radio-group.tsx @@ -35,4 +35,43 @@ const RadioGroupItem = React.forwardRef< RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName; -export { RadioGroup, RadioGroupItem }; +/** + * A segmented-control style radio group where each item renders as a small + * toggle button rather than a radio circle. + */ +const RadioGroupSegmented = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ); +}); + +RadioGroupSegmented.displayName = 'RadioGroupSegmented'; + +const RadioGroupSegmentedItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => { + return ( + + {children} + + ); +}); + +RadioGroupSegmentedItem.displayName = 'RadioGroupSegmentedItem'; + +export { RadioGroup, RadioGroupItem, RadioGroupSegmented, RadioGroupSegmentedItem }; From 9c27ce6d185f7e25eec80439b931c4709723a2ae Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Mon, 3 Aug 2026 22:52:11 +1000 Subject: [PATCH 14/27] feat: replace document status tabs with filter pills (#3145) Swaps the tab row and dropdowns for faceted filter pills (status, sender, period) with a shared reset, and moves URL param handling to nuqs. image --- .../general/document/document-search.tsx | 34 +--- .../app/components/general/filter-pill.tsx | 186 ++++++++++++++++++ .../tables/documents-table-period-filter.tsx | 40 ++++ .../tables/documents-table-sender-filter.tsx | 64 +++--- .../tables/documents-table-status-filter.tsx | 98 +++++++++ .../t.$teamUrl+/documents._index.tsx | 165 +++++----------- .../app/utils/documents-search-params.ts | 19 ++ .../e2e/documents/cancel-documents.spec.ts | 11 +- .../e2e/documents/delete-documents.spec.ts | 44 +---- .../e2e/documents/find-documents.spec.ts | 57 ++---- packages/app-tests/e2e/fixtures/documents.ts | 113 ++++++++++- .../e2e/teams/team-documents.spec.ts | 68 ++----- 12 files changed, 592 insertions(+), 307 deletions(-) create mode 100644 apps/remix/app/components/general/filter-pill.tsx create mode 100644 apps/remix/app/components/tables/documents-table-period-filter.tsx create mode 100644 apps/remix/app/components/tables/documents-table-status-filter.tsx create mode 100644 apps/remix/app/utils/documents-search-params.ts diff --git a/apps/remix/app/components/general/document/document-search.tsx b/apps/remix/app/components/general/document/document-search.tsx index 9079be8f8..bb0819008 100644 --- a/apps/remix/app/components/general/document/document-search.tsx +++ b/apps/remix/app/components/general/document/document-search.tsx @@ -2,38 +2,24 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce import { Input } from '@documenso/ui/primitives/input'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; -import { useCallback, useEffect, useState } from 'react'; -import { useSearchParams } from 'react-router'; +import { useQueryState } from 'nuqs'; +import { useEffect, useState } from 'react'; -export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string }) => { +import { documentsSearchParams } from '~/utils/documents-search-params'; + +export const DocumentSearch = () => { const { _ } = useLingui(); - const [searchParams, setSearchParams] = useSearchParams(); + const [query, setQuery] = useQueryState('query', documentsSearchParams.query); - const [searchTerm, setSearchTerm] = useState(initialValue); + const [searchTerm, setSearchTerm] = useState(query ?? ''); const debouncedSearchTerm = useDebouncedValue(searchTerm, 500); - const handleSearch = useCallback( - (term: string) => { - const params = new URLSearchParams(searchParams?.toString() ?? ''); - if (term) { - params.set('query', term); - } else { - params.delete('query'); - } - - setSearchParams(params); - }, - [searchParams], - ); - useEffect(() => { - const currentQueryParam = searchParams.get('query') || ''; - - if (debouncedSearchTerm !== currentQueryParam) { - handleSearch(debouncedSearchTerm); + if (debouncedSearchTerm !== (query ?? '')) { + void setQuery(debouncedSearchTerm || null); } - }, [debouncedSearchTerm, searchParams]); + }, [debouncedSearchTerm, query, setQuery]); return ( void; + selectedLabel?: ReactNode; +}; + +export type FilterPillMultipleProps = FilterPillCommonProps & { + multiple: true; + value: string[]; + onChange: (value: string[]) => void; +}; + +export type FilterPillProps = FilterPillSingleProps | FilterPillMultipleProps; + +/** + * A faceted filter pill. + * + * Renders as a dashed "add a filter" pill at rest, and shows the current + * selection inline once a value is picked. Selecting the active option + * again (or the Clear row) removes it. + * + * Single select by default, closing on pick. When `multiple` is set the + * popover stays open for toggling, and the trigger shows the first two + * selections followed by a "+N more" chip. + */ +export const FilterPill = (props: FilterPillProps) => { + const { icon: Icon, label, options, enableSearch, searchPlaceholder, loading, testId } = props; + + const [open, setOpen] = useState(false); + + const selectedValues = props.multiple ? props.value : props.value === null ? [] : [props.value]; + + const selectedOptions = selectedValues + .map((value) => options.find((option) => option.value === value)) + .filter((option): option is FilterPillOption => option !== undefined); + + const hasSelection = selectedOptions.length > 0; + const extraCount = selectedOptions.length - 2; + + const onSelect = (nextValue: string) => { + if (props.multiple) { + const newValues = selectedValues.includes(nextValue) + ? selectedValues.filter((value) => value !== nextValue) + : [...selectedValues, nextValue]; + + props.onChange(newValues); + return; + } + + props.onChange(nextValue === props.value ? null : nextValue); + setOpen(false); + }; + + const onClear = () => { + if (props.multiple) { + props.onChange([]); + } else { + props.onChange(null); + } + + setOpen(false); + }; + + return ( + + + + + + + + {enableSearch && } + + + + No results found. + + + + {options.map((option) => ( + onSelect(option.value)}> + + + {option.label} + + {option.trailing !== undefined && ( + {option.trailing} + )} + + ))} + + + {hasSelection && ( + <> + + + + Clear + + + + )} + + + + + ); +}; diff --git a/apps/remix/app/components/tables/documents-table-period-filter.tsx b/apps/remix/app/components/tables/documents-table-period-filter.tsx new file mode 100644 index 000000000..b051b41fa --- /dev/null +++ b/apps/remix/app/components/tables/documents-table-period-filter.tsx @@ -0,0 +1,40 @@ +import { Trans } from '@lingui/react/macro'; +import { CalendarIcon } from 'lucide-react'; +import { useQueryStates } from 'nuqs'; + +import { FilterPill } from '~/components/general/filter-pill'; +import { DOCUMENTS_PERIOD_VALUES, documentsSearchParams } from '~/utils/documents-search-params'; + +const PERIOD_OPTIONS = [ + { value: '7d', label: Last 7 days }, + { value: '14d', label: Last 14 days }, + { value: '30d', label: Last 30 days }, +]; + +export const DocumentsTablePeriodFilter = () => { + const [{ period }, setSearchParams] = useQueryStates( + { + period: documentsSearchParams.period, + page: documentsSearchParams.page, + }, + { history: 'push' }, + ); + + const onChange = (newPeriod: string | null) => { + void setSearchParams({ + period: DOCUMENTS_PERIOD_VALUES.find((value) => value === newPeriod) ?? null, + page: null, + }); + }; + + return ( + Period} + value={period} + onChange={onChange} + options={PERIOD_OPTIONS} + testId="documents-table-period-filter" + /> + ); +}; diff --git a/apps/remix/app/components/tables/documents-table-sender-filter.tsx b/apps/remix/app/components/tables/documents-table-sender-filter.tsx index c4c2bbd4a..1d398fb02 100644 --- a/apps/remix/app/components/tables/documents-table-sender-filter.tsx +++ b/apps/remix/app/components/tables/documents-table-sender-filter.tsx @@ -1,63 +1,61 @@ import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted'; import { trpc } from '@documenso/trpc/react'; -import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox'; import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import { useLocation, useNavigate, useSearchParams } from 'react-router'; +import { UserIcon } from 'lucide-react'; +import { useQueryStates } from 'nuqs'; + +import { FilterPill } from '~/components/general/filter-pill'; +import { documentsSearchParams } from '~/utils/documents-search-params'; type DocumentsTableSenderFilterProps = { teamId: number; }; export const DocumentsTableSenderFilter = ({ teamId }: DocumentsTableSenderFilterProps) => { - const { pathname } = useLocation(); - const [searchParams] = useSearchParams(); - const navigate = useNavigate(); + const { _ } = useLingui(); const isMounted = useIsMounted(); - const senderIds = (searchParams?.get('senderIds') ?? '').split(',').filter((value) => value !== ''); + const [{ senderIds }, setSearchParams] = useQueryStates( + { + senderIds: documentsSearchParams.senderIds, + page: documentsSearchParams.page, + }, + { history: 'push' }, + ); + + const selectedSenderIds = (senderIds ?? []).map((senderId) => senderId.toString()); const { data, isLoading } = trpc.team.member.getMany.useQuery({ teamId, }); - const comboBoxOptions = (data ?? []).map((member) => ({ + const options = (data ?? []).map((member) => ({ label: member.name ?? member.email, value: member.userId.toString(), })); const onChange = (newSenderIds: string[]) => { - if (!pathname) { - return; - } - - const params = new URLSearchParams(searchParams?.toString()); - - params.set('senderIds', newSenderIds.join(',')); - - if (newSenderIds.length === 0) { - params.delete('senderIds'); - } - - void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true }); + void setSearchParams({ + senderIds: newSenderIds.length > 0 ? newSenderIds.map(Number) : null, + page: null, + }); }; return ( - - - Sender: All - -

- } - enableClearAllButton={true} - inputPlaceholder={msg`Search`} - loading={!isMounted || isLoading} - options={comboBoxOptions} - selectedValues={senderIds} + Sender} + value={selectedSenderIds} onChange={onChange} + options={options} + enableSearch + searchPlaceholder={_(msg`Search members...`)} + loading={!isMounted || isLoading} + testId="documents-table-sender-filter" /> ); }; diff --git a/apps/remix/app/components/tables/documents-table-status-filter.tsx b/apps/remix/app/components/tables/documents-table-status-filter.tsx new file mode 100644 index 000000000..25abc1164 --- /dev/null +++ b/apps/remix/app/components/tables/documents-table-status-filter.tsx @@ -0,0 +1,98 @@ +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { STATS_COUNT_CAP } from '@documenso/lib/constants/document'; +import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; +import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { OrganisationType } from '@prisma/client'; +import { ListFilterIcon } from 'lucide-react'; +import { useQueryStates } from 'nuqs'; +import { useMemo } from 'react'; + +import { DocumentStatus, FRIENDLY_STATUS_MAP } from '~/components/general/document/document-status'; +import { FilterPill } from '~/components/general/filter-pill'; +import { documentsSearchParams } from '~/utils/documents-search-params'; + +type DocumentsTableStatusFilterProps = { + stats: TFindDocumentsInternalResponse['stats']; +}; + +export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilterProps) => { + const { _ } = useLingui(); + + const organisation = useCurrentOrganisation(); + + const [{ status }, setSearchParams] = useQueryStates( + { + status: documentsSearchParams.status, + page: documentsSearchParams.page, + }, + { history: 'push' }, + ); + + const selectableStatuses = useMemo( + () => + SELECTABLE_STATUSES.filter((value) => { + if (organisation.type === OrganisationType.PERSONAL) { + return value !== ExtendedDocumentStatus.INBOX; + } + + return true; + }), + [organisation.type], + ); + + const selectedStatus = useMemo( + () => selectableStatuses.find((value) => value === status) ?? null, + [selectableStatuses, status], + ); + + const onChange = (newStatus: string | null) => { + void setSearchParams({ + status: selectableStatuses.find((value) => value === newStatus) ?? null, + page: null, + }); + }; + + return ( + <> + Status} + value={selectedStatus} + onChange={onChange} + selectedLabel={selectedStatus && } + options={selectableStatuses.map((value) => ({ + value, + label: , + trailing: formatStatsCount(stats[value]), + }))} + testId="documents-table-status-filter" + /> + + {/* Visually hidden document counts, for screen readers and tests. */} + + {[...selectableStatuses, ExtendedDocumentStatus.ALL].map((value) => ( + + {_(FRIENDLY_STATUS_MAP[value].label)}:{' '} + {stats[value]} + + ))} + + + ); +}; + +const SELECTABLE_STATUSES: ExtendedDocumentStatus[] = [ + ExtendedDocumentStatus.INBOX, + ExtendedDocumentStatus.PENDING, + ExtendedDocumentStatus.COMPLETED, + ExtendedDocumentStatus.CANCELLED, + ExtendedDocumentStatus.DRAFT, + ExtendedDocumentStatus.REJECTED, + ExtendedDocumentStatus.EXPIRED, +]; + +const formatStatsCount = (count: number) => { + return count >= STATS_COUNT_CAP ? `${STATS_COUNT_CAP.toLocaleString()}+` : count.toString(); +}; diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx index 37883b796..9f5015542 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx @@ -1,28 +1,20 @@ import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage'; -import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; -import { STATS_COUNT_CAP } from '@documenso/lib/constants/document'; import { SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc'; import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; -import { parseToIntegerArray } from '@documenso/lib/utils/params'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import { trpc } from '@documenso/trpc/react'; import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types'; -import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/find-documents-internal.types'; import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; +import { Button } from '@documenso/ui/primitives/button'; import type { RowSelectionState } from '@documenso/ui/primitives/data-table'; -import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; import { msg } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; -import { - EnvelopeType, - FolderType, - OrganisationType, - type DocumentStatus as PrismaDocumentStatus, -} from '@prisma/client'; +import { EnvelopeType, FolderType, type DocumentStatus as PrismaDocumentStatus } from '@prisma/client'; +import { XIcon } from 'lucide-react'; +import { useQueryStates } from 'nuqs'; import { useEffect, useMemo, useState } from 'react'; -import { Link, useNavigate, useParams, useSearchParams } from 'react-router'; -import { z } from 'zod'; +import { useNavigate, useParams } from 'react-router'; import { EnvelopesBulkCancelDialog } from '~/components/dialogs/envelopes-bulk-cancel-dialog'; import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog'; @@ -32,15 +24,16 @@ import { } from '~/components/dialogs/envelopes-bulk-download-dialog'; import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog'; import { DocumentSearch } from '~/components/general/document/document-search'; -import { DocumentStatus } from '~/components/general/document/document-status'; import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper'; import { FolderGrid } from '~/components/general/folder/folder-grid'; -import { PeriodSelector } from '~/components/general/period-selector'; import { DocumentsTable } from '~/components/tables/documents-table'; import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state'; +import { DocumentsTablePeriodFilter } from '~/components/tables/documents-table-period-filter'; import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter'; +import { DocumentsTableStatusFilter } from '~/components/tables/documents-table-status-filter'; import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar'; import { useCurrentTeam } from '~/providers/team'; +import { documentsSearchParams } from '~/utils/documents-search-params'; import { appMetaTags } from '~/utils/meta'; export function meta() { @@ -55,22 +48,10 @@ type EnvelopeMetaCache = Record ZSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {}, - [searchParams], - ); + const [findDocumentSearchParams, setFindDocumentSearchParams] = useQueryStates(documentsSearchParams, { + history: 'push', + }); const { data, isLoading, isLoadingError } = trpc.document.findDocumentsInternal.useQuery( { - ...findDocumentSearchParams, + status: findDocumentSearchParams.status ?? undefined, + period: findDocumentSearchParams.period ?? undefined, + senderIds: findDocumentSearchParams.senderIds ?? undefined, + page: findDocumentSearchParams.page ?? undefined, + perPage: findDocumentSearchParams.perPage ?? undefined, + query: findDocumentSearchParams.query ?? undefined, folderId, }, { @@ -167,34 +152,21 @@ export default function DocumentsPage() { .filter((item): item is EnvelopeBulkDownloadItem => item !== null); }, [selectedEnvelopeIds, envelopeMetaCache]); - const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => { - const params = new URLSearchParams(searchParams); + const hasActiveFilters = useMemo(() => { + return Boolean( + (findDocumentSearchParams.status && findDocumentSearchParams.status !== ExtendedDocumentStatus.ALL) || + findDocumentSearchParams.senderIds?.length || + findDocumentSearchParams.period, + ); + }, [findDocumentSearchParams]); - params.set('status', value); - - if (value === ExtendedDocumentStatus.ALL) { - params.delete('status'); - } - - if (value === ExtendedDocumentStatus.INBOX && organisation.type === OrganisationType.PERSONAL) { - params.delete('status'); - } - - if (params.has('page')) { - params.delete('page'); - } - - let path = formatDocumentsPath(team.url); - - if (folderId) { - path += `/f/${folderId}`; - } - - if (params.toString()) { - path += `?${params.toString()}`; - } - - return path; + const onResetFilters = () => { + void setFindDocumentSearchParams({ + status: null, + senderIds: null, + period: null, + page: null, + }); }; useEffect(() => { @@ -208,69 +180,40 @@ export default function DocumentsPage() {
-
-
- - {team.avatarImageId && } - {team.name.slice(0, 1)} - +
+ + {team.avatarImageId && } + {team.name.slice(0, 1)} + -

- Documents -

+

+ Documents +

+
+ +
+
+
-
- - - {[ - ExtendedDocumentStatus.INBOX, - ExtendedDocumentStatus.PENDING, - ExtendedDocumentStatus.COMPLETED, - ExtendedDocumentStatus.CANCELLED, - ExtendedDocumentStatus.DRAFT, - ExtendedDocumentStatus.REJECTED, - ExtendedDocumentStatus.EXPIRED, - ExtendedDocumentStatus.ALL, - ] - .filter((value) => { - if (organisation.type === OrganisationType.PERSONAL) { - return value !== ExtendedDocumentStatus.INBOX; - } + - return true; - }) - .map((value) => ( - - - + {team && } - {value !== ExtendedDocumentStatus.ALL && ( - - {stats[value] >= STATS_COUNT_CAP ? `${STATS_COUNT_CAP.toLocaleString()}+` : stats[value]} - - )} - - - ))} - - + - {team && } - -
- -
-
- -
-
+ {hasActiveFilters && ( + + )}
{data && data.count === 0 ? ( - + ) : ( { @@ -207,11 +203,7 @@ test('[DOCUMENTS]: deleting pending documents should permanently remove it', asy await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible(); // Check document counts. - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 0); - await checkDocumentTabCount(page, 'Completed', 1); - await checkDocumentTabCount(page, 'Draft', 1); - await checkDocumentTabCount(page, 'All', 2); + await checkDocumentCounts(page, { inbox: 0, pending: 0, completed: 1, draft: 1, all: 2 }); }); test('[DOCUMENTS]: deleting completed documents as an owner should hide it from only the owner', async ({ page }) => { @@ -239,11 +231,7 @@ test('[DOCUMENTS]: deleting completed documents as an owner should hide it from // Check document counts. await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible(); - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 1); - await checkDocumentTabCount(page, 'Completed', 0); - await checkDocumentTabCount(page, 'Draft', 1); - await checkDocumentTabCount(page, 'All', 2); + await checkDocumentCounts(page, { inbox: 0, pending: 1, completed: 0, draft: 1, all: 2 }); // Sign into the recipient account. await apiSignout({ page }); @@ -255,11 +243,7 @@ test('[DOCUMENTS]: deleting completed documents as an owner should hide it from // Check document counts. await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).toBeVisible(); - await checkDocumentTabCount(page, 'Inbox', 1); - await checkDocumentTabCount(page, 'Pending', 0); - await checkDocumentTabCount(page, 'Completed', 1); - await checkDocumentTabCount(page, 'Draft', 0); - await checkDocumentTabCount(page, 'All', 2); + await checkDocumentCounts(page, { inbox: 1, pending: 0, completed: 1, draft: 0, all: 2 }); }); test('[DOCUMENTS]: deleting documents as a recipient should only hide it for them', async ({ page }) => { @@ -300,11 +284,7 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the // Check document counts. await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible(); await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible(); - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 0); - await checkDocumentTabCount(page, 'Completed', 0); - await checkDocumentTabCount(page, 'Draft', 0); - await checkDocumentTabCount(page, 'All', 0); + await checkDocumentCounts(page, { inbox: 0, pending: 0, completed: 0, draft: 0, all: 0 }); // Sign into the sender account. await apiSignout({ page }); @@ -315,11 +295,7 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the }); // Check document counts for sender. - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 1); - await checkDocumentTabCount(page, 'Completed', 1); - await checkDocumentTabCount(page, 'Draft', 1); - await checkDocumentTabCount(page, 'All', 3); + await checkDocumentCounts(page, { inbox: 0, pending: 1, completed: 1, draft: 1, all: 3 }); // Sign into the other recipient account. await apiSignout({ page }); @@ -330,9 +306,5 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the }); // Check document counts for other recipient. - await checkDocumentTabCount(page, 'Inbox', 1); - await checkDocumentTabCount(page, 'Pending', 0); - await checkDocumentTabCount(page, 'Completed', 1); - await checkDocumentTabCount(page, 'Draft', 0); - await checkDocumentTabCount(page, 'All', 2); + await checkDocumentCounts(page, { inbox: 1, pending: 0, completed: 1, draft: 0, all: 2 }); }); diff --git a/packages/app-tests/e2e/documents/find-documents.spec.ts b/packages/app-tests/e2e/documents/find-documents.spec.ts index 960a09863..143c6225b 100644 --- a/packages/app-tests/e2e/documents/find-documents.spec.ts +++ b/packages/app-tests/e2e/documents/find-documents.spec.ts @@ -20,7 +20,7 @@ import { } from '@prisma/client'; import { apiSignin, apiSignout } from '../fixtures/authentication'; -import { checkDocumentTabCount } from '../fixtures/documents'; +import { checkDocumentCounts, checkDocumentTabCount, toggleDocumentSenderFilter } from '../fixtures/documents'; test.describe.configure({ mode: 'parallel', @@ -61,10 +61,7 @@ test.describe('Find Documents UI - Personal Context', () => { redirectPath: `/t/${team.url}/documents`, }); - await checkDocumentTabCount(page, 'All', 3); - await checkDocumentTabCount(page, 'Draft', 1); - await checkDocumentTabCount(page, 'Pending', 1); - await checkDocumentTabCount(page, 'Completed', 1); + await checkDocumentCounts(page, { draft: 1, pending: 1, completed: 1, all: 3 }); }); test('received documents from other teams should NOT appear in personal context', async ({ page }) => { @@ -140,10 +137,9 @@ test.describe('Find Documents UI - Personal Context', () => { redirectPath: `/t/${ownerTeam.url}/documents`, }); - // Inbox should be 0 since there's no team email and received docs are on sender's team - await checkDocumentTabCount(page, 'Inbox', 0); - // Owner's own doc should still show in All - await checkDocumentTabCount(page, 'All', 1); + // Inbox should be 0 since there's no team email and received docs are on sender's team. + // Owner's own doc should still show in All. + await checkDocumentCounts(page, { inbox: 0, all: 1 }); await expect(page.getByRole('link', { name: 'Owner Draft Control' })).toBeVisible(); }); @@ -707,9 +703,8 @@ test.describe('Find Documents UI - Team with Team Email', () => { redirectPath: `/t/${team.url}/documents`, }); - await checkDocumentTabCount(page, 'Inbox', 0); - // But pending should still show - await checkDocumentTabCount(page, 'Pending', 1); + // Inbox should be 0, but pending should still show. + await checkDocumentCounts(page, { inbox: 0, pending: 1 }); }); test('documents sent BY team email user should appear in team context', async ({ page }) => { @@ -810,12 +805,9 @@ test.describe('Find Documents UI - Data Isolation & No Leaking', () => { }); // UserA should see only their own docs - await checkDocumentTabCount(page, 'All', 3); - await checkDocumentTabCount(page, 'Draft', 1); - await checkDocumentTabCount(page, 'Completed', 1); + await checkDocumentCounts(page, { draft: 1, completed: 1, all: 3 }); // Verify no B docs leaked - await page.getByRole('tab', { name: 'All' }).click(); await expect(page.getByRole('link', { name: 'A Own Draft' })).toBeVisible(); await expect(page.getByRole('link', { name: 'B Draft Private', exact: true })).not.toBeVisible(); await expect(page.getByRole('link', { name: 'B Pending Private', exact: true })).not.toBeVisible(); @@ -966,9 +958,9 @@ test.describe('Find Documents UI - Data Isolation & No Leaking', () => { redirectPath: `/t/${outsideTeam.url}/documents`, }); - // Only the outside user's own draft should appear (cross-team docs are not visible) - await checkDocumentTabCount(page, 'Inbox', 0); // No team email → 0 - await checkDocumentTabCount(page, 'All', 1); // Check All tab last so we can verify visible links + // Only the outside user's own draft should appear (cross-team docs are not visible). + // Inbox is 0 since there is no team email. + await checkDocumentCounts(page, { inbox: 0, all: 1 }); await expect(page.getByRole('link', { name: 'Outside Own Draft' })).toBeVisible(); await expect(page.getByRole('link', { name: 'Team Doc For Outside User', exact: true })).not.toBeVisible(); await expect(page.getByRole('link', { name: 'Team Doc For Other User Only', exact: true })).not.toBeVisible(); @@ -1013,12 +1005,10 @@ test.describe('Find Documents UI - Tab Counts Consistency', () => { redirectPath: `/t/${ownerTeam.url}/documents`, }); - // Only owner's own docs appear (received docs are on sender's team) - await checkDocumentTabCount(page, 'Draft', 2); - await checkDocumentTabCount(page, 'Pending', 1); - await checkDocumentTabCount(page, 'Inbox', 0); // No team email → inbox returns null → 0 - await checkDocumentTabCount(page, 'Completed', 1); // Only owned completed (received is on sender's team) - await checkDocumentTabCount(page, 'All', 4); // 2 drafts + 1 pending + 1 completed + // Only owner's own docs appear (received docs are on sender's team). + // Inbox is 0 since there is no team email, and only the owned completed + // doc counts (received is on sender's team). All = 2 drafts + 1 pending + 1 completed. + await checkDocumentCounts(page, { inbox: 0, draft: 2, pending: 1, completed: 1, all: 4 }); }); test('team context tab counts should be accurate with mixed documents', async ({ page }) => { @@ -1070,10 +1060,7 @@ test.describe('Find Documents UI - Tab Counts Consistency', () => { redirectPath: `/t/${team.url}/documents`, }); - await checkDocumentTabCount(page, 'Draft', 2); - await checkDocumentTabCount(page, 'Pending', 1); - await checkDocumentTabCount(page, 'Completed', 1); - await checkDocumentTabCount(page, 'All', 4); + await checkDocumentCounts(page, { draft: 2, pending: 1, completed: 1, all: 4 }); }); test('team with team email tab counts should include received documents', async ({ page }) => { @@ -1107,11 +1094,9 @@ test.describe('Find Documents UI - Tab Counts Consistency', () => { redirectPath: `/t/${team.url}/documents`, }); - await checkDocumentTabCount(page, 'Draft', 1); - await checkDocumentTabCount(page, 'Inbox', 1); // One pending doc received by team email (NOT_SIGNED) - await checkDocumentTabCount(page, 'Pending', 1); // Own pending - await checkDocumentTabCount(page, 'Completed', 1); // Received completed via email - await checkDocumentTabCount(page, 'All', 4); // All of the above + // Inbox = one pending doc received by team email (NOT_SIGNED), pending = own + // pending, completed = received completed via email, all = all of the above. + await checkDocumentCounts(page, { inbox: 1, draft: 1, pending: 1, completed: 1, all: 4 }); }); }); @@ -1163,9 +1148,7 @@ test.describe('Find Documents UI - Sender Filter', () => { await checkDocumentTabCount(page, 'All', 3); // Filter by member1 - await page.locator('button').filter({ hasText: 'Sender: All' }).click(); - await page.getByRole('option', { name: member1.name ?? '' }).click(); - await page.waitForURL(/senderIds/); + await toggleDocumentSenderFilter(page, member1.name ?? ''); // Should only show member1's doc await checkDocumentTabCount(page, 'All', 1); diff --git a/packages/app-tests/e2e/fixtures/documents.ts b/packages/app-tests/e2e/fixtures/documents.ts index 160dc1030..fbc49241a 100644 --- a/packages/app-tests/e2e/fixtures/documents.ts +++ b/packages/app-tests/e2e/fixtures/documents.ts @@ -1,11 +1,116 @@ import type { Page } from '@playwright/test'; import { expect } from '@playwright/test'; -export const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => { - await page.getByRole('tab', { name: tabName }).click(); +type DocumentStatusCounts = { + inbox?: number; + pending?: number; + completed?: number; + draft?: number; + cancelled?: number; + rejected?: number; + expired?: number; + all?: number; +}; - if (tabName !== 'All') { - await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString()); +const STATUS_KEYS = { + inbox: 'INBOX', + pending: 'PENDING', + completed: 'COMPLETED', + draft: 'DRAFT', + cancelled: 'CANCELLED', + rejected: 'REJECTED', + expired: 'EXPIRED', + all: 'ALL', +} as const; + +/** + * Check the counts for multiple document statuses in one go via the + * visually hidden stats rendered alongside the status filter. + * + * When `all` is provided the status filter is also cleared and the + * unfiltered table count (or empty state) is verified. + */ +export const checkDocumentCounts = async (page: Page, counts: DocumentStatusCounts) => { + for (const [key, status] of Object.entries(STATUS_KEYS)) { + const count = counts[key as keyof typeof STATUS_KEYS]; + + if (count === undefined) { + continue; + } + + await expect(page.getByTestId(`documents-status-count-${status}`)).toHaveText(count.toString()); + } + + if (counts.all !== undefined) { + await clearDocumentStatusFilter(page); + + if (counts.all === 0) { + await expect(page.getByTestId('empty-document-state')).toBeVisible(); + return; + } + + await expect(page.getByTestId('data-table-count')).toContainText(`Showing ${counts.all}`); + } +}; + +/** + * Select a status in the documents status filter pill. + * + * No-op if the status is already selected, since selecting the active + * option again would clear the filter. + */ +export const selectDocumentStatusFilter = async (page: Page, statusName: string) => { + const currentStatus = new URL(page.url()).searchParams.get('status'); + + if (currentStatus === statusName.toUpperCase()) { + return; + } + + await page.getByTestId('documents-table-status-filter').click(); + await page.getByRole('option', { name: statusName }).click(); +}; + +/** + * Toggle a sender in the documents sender filter pill. + * + * The sender filter is a multi select, so the popover stays open after + * picking and is closed with Escape. + */ +export const toggleDocumentSenderFilter = async (page: Page, senderName: string) => { + await page.getByTestId('documents-table-sender-filter').click(); + await page.getByRole('option', { name: senderName }).click(); + await page.waitForURL(/senderIds/); + await page.keyboard.press('Escape'); +}; + +/** + * Clear the documents status filter pill, returning to the "All" view. + */ +export const clearDocumentStatusFilter = async (page: Page) => { + const currentStatus = new URL(page.url()).searchParams.get('status'); + + if (!currentStatus) { + return; + } + + await page.getByTestId('documents-table-status-filter').click(); + await page.getByRole('option', { name: 'Clear' }).click(); +}; + +/** + * Apply a status filter (or 'All' to clear it) and verify both the hidden + * stats count and the resulting table. + * + * The count is not asserted against the stats for 'All', since tests use it + * with search queries applied which only the table respects. + */ +export const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => { + if (tabName === 'All') { + await clearDocumentStatusFilter(page); + } else { + await expect(page.getByTestId(`documents-status-count-${tabName.toUpperCase()}`)).toHaveText(count.toString()); + + await selectDocumentStatusFilter(page, tabName); } if (count === 0) { diff --git a/packages/app-tests/e2e/teams/team-documents.spec.ts b/packages/app-tests/e2e/teams/team-documents.spec.ts index 6d5f2ca08..c8a7df77e 100644 --- a/packages/app-tests/e2e/teams/team-documents.spec.ts +++ b/packages/app-tests/e2e/teams/team-documents.spec.ts @@ -5,7 +5,7 @@ import { expect, test } from '@playwright/test'; import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@prisma/client'; import { apiSignin, apiSignout } from '../fixtures/authentication'; -import { checkDocumentTabCount } from '../fixtures/documents'; +import { checkDocumentCounts, checkDocumentTabCount, toggleDocumentSenderFilter } from '../fixtures/documents'; import { expectTextToBeVisible, expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic'; test('[TEAMS]: check team documents count', async ({ page }) => { @@ -20,23 +20,13 @@ test('[TEAMS]: check team documents count', async ({ page }) => { }); // Check document counts. - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 2); - await checkDocumentTabCount(page, 'Completed', 1); - await checkDocumentTabCount(page, 'Draft', 2); - await checkDocumentTabCount(page, 'All', 5); + await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 1, draft: 2, all: 5 }); // Apply filter. - await page.locator('button').filter({ hasText: 'Sender: All' }).click(); - await page.getByRole('option', { name: teamMember2.name ?? '' }).click(); - await page.waitForURL(/senderIds/); + await toggleDocumentSenderFilter(page, teamMember2.name ?? ''); // Check counts after filtering. - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 2); - await checkDocumentTabCount(page, 'Completed', 0); - await checkDocumentTabCount(page, 'Draft', 1); - await checkDocumentTabCount(page, 'All', 3); + await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 0, draft: 1, all: 3 }); await apiSignout({ page }); } @@ -115,23 +105,13 @@ test('[TEAMS]: check team documents count with internal team email', async ({ pa }); // Check document counts. - await checkDocumentTabCount(page, 'Inbox', 2); - await checkDocumentTabCount(page, 'Pending', 3); - await checkDocumentTabCount(page, 'Completed', 3); - await checkDocumentTabCount(page, 'Draft', 3); - await checkDocumentTabCount(page, 'All', 11); + await checkDocumentCounts(page, { inbox: 2, pending: 3, completed: 3, draft: 3, all: 11 }); // Apply filter. - await page.locator('button').filter({ hasText: 'Sender: All' }).click(); - await page.getByRole('option', { name: teamMember2.name ?? '' }).click(); - await page.waitForURL(/senderIds/); + await toggleDocumentSenderFilter(page, teamMember2.name ?? ''); // Check counts after filtering. - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 2); - await checkDocumentTabCount(page, 'Completed', 0); - await checkDocumentTabCount(page, 'Draft', 1); - await checkDocumentTabCount(page, 'All', 3); + await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 0, draft: 1, all: 3 }); await apiSignout({ page }); } @@ -202,23 +182,13 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa }); // Check document counts. - await checkDocumentTabCount(page, 'Inbox', 3); - await checkDocumentTabCount(page, 'Pending', 2); - await checkDocumentTabCount(page, 'Completed', 2); - await checkDocumentTabCount(page, 'Draft', 2); - await checkDocumentTabCount(page, 'All', 9); + await checkDocumentCounts(page, { inbox: 3, pending: 2, completed: 2, draft: 2, all: 9 }); // Apply filter. - await page.locator('button').filter({ hasText: 'Sender: All' }).click(); - await page.getByRole('option', { name: teamMember2.name ?? '' }).click(); - await page.waitForURL(/senderIds/); + await toggleDocumentSenderFilter(page, teamMember2.name ?? ''); // Check counts after filtering. - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 2); - await checkDocumentTabCount(page, 'Completed', 0); - await checkDocumentTabCount(page, 'Draft', 1); - await checkDocumentTabCount(page, 'All', 3); + await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 0, draft: 1, all: 3 }); }); test('[TEAMS]: resend pending team document', async ({ page }) => { @@ -273,11 +243,7 @@ test('[TEAMS]: delete draft team document', async ({ page }) => { }); // Check document counts. - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 2); - await checkDocumentTabCount(page, 'Completed', 1); - await checkDocumentTabCount(page, 'Draft', 1); - await checkDocumentTabCount(page, 'All', 4); + await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 1, draft: 1, all: 4 }); await apiSignout({ page }); } @@ -316,11 +282,7 @@ test('[TEAMS]: delete pending team document', async ({ page }) => { }); // Check document counts. - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 1); - await checkDocumentTabCount(page, 'Completed', 1); - await checkDocumentTabCount(page, 'Draft', 2); - await checkDocumentTabCount(page, 'All', 4); + await checkDocumentCounts(page, { inbox: 0, pending: 1, completed: 1, draft: 2, all: 4 }); await apiSignout({ page }); } @@ -359,11 +321,7 @@ test('[TEAMS]: delete completed team document', async ({ page }) => { }); // Check document counts. - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 2); - await checkDocumentTabCount(page, 'Completed', 0); - await checkDocumentTabCount(page, 'Draft', 2); - await checkDocumentTabCount(page, 'All', 4); + await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 0, draft: 2, all: 4 }); await apiSignout({ page }); } From 8bfcec8ee64d87fe79a56f08a15e197e0e7aecc1 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Wed, 5 Aug 2026 08:12:55 +1000 Subject: [PATCH 15/27] fix: add logging for errors on sign or complete (#3149) --- packages/trpc/server/field-router/router.ts | 71 ++++++++++------ .../trpc/server/recipient-router/router.ts | 83 +++++++++++-------- 2 files changed, 94 insertions(+), 60 deletions(-) diff --git a/packages/trpc/server/field-router/router.ts b/packages/trpc/server/field-router/router.ts index 39d207c5c..4d14e9de7 100644 --- a/packages/trpc/server/field-router/router.ts +++ b/packages/trpc/server/field-router/router.ts @@ -1,3 +1,4 @@ +import { AppError } from '@documenso/lib/errors/app-error'; import { createEnvelopeFields } from '@documenso/lib/server-only/field/create-envelope-fields'; import { deleteDocumentField } from '@documenso/lib/server-only/field/delete-document-field'; import { deleteTemplateField } from '@documenso/lib/server-only/field/delete-template-field'; @@ -613,23 +614,34 @@ export const fieldRouter = router({ * @private */ signFieldWithToken: procedure.input(ZSignFieldWithTokenMutationSchema).mutation(async ({ input, ctx }) => { - const { token, fieldId, value, isBase64, authOptions } = input; + try { + const { token, fieldId, value, isBase64, authOptions } = input; - ctx.logger.info({ - input: { + ctx.logger.info({ + input: { + fieldId, + }, + }); + + return await signFieldWithToken({ + token, fieldId, - }, - }); + value: value ?? '', + isBase64, + userId: ctx.user?.id, + authOptions, + requestMetadata: ctx.metadata.requestMetadata, + }); + } catch (err) { + // Log the error for debugging purposes. + ctx.logger.error({ + message: 'Error signing field with token', + error: err instanceof AppError ? `[${err.code}]: ${err.message}` : err, + }); - return await signFieldWithToken({ - token, - fieldId, - value: value ?? '', - isBase64, - userId: ctx.user?.id, - authOptions, - requestMetadata: ctx.metadata.requestMetadata, - }); + // Rethrow the error so that the client receives the appropriate error response. + throw err; + } }), /** @@ -638,18 +650,29 @@ export const fieldRouter = router({ removeSignedFieldWithToken: procedure .input(ZRemovedSignedFieldWithTokenMutationSchema) .mutation(async ({ input, ctx }) => { - const { token, fieldId } = input; + try { + const { token, fieldId } = input; - ctx.logger.info({ - input: { + ctx.logger.info({ + input: { + fieldId, + }, + }); + + return await removeSignedFieldWithToken({ + token, fieldId, - }, - }); + requestMetadata: ctx.metadata.requestMetadata, + }); + } catch (err) { + // Log the error for debugging purposes. + ctx.logger.error({ + message: 'Error removing signed field with token', + error: err instanceof AppError ? `[${err.code}]: ${err.message}` : err, + }); - return await removeSignedFieldWithToken({ - token, - fieldId, - requestMetadata: ctx.metadata.requestMetadata, - }); + // Rethrow the error so that the client receives the appropriate error response. + throw err; + } }), }); diff --git a/packages/trpc/server/recipient-router/router.ts b/packages/trpc/server/recipient-router/router.ts index 72c4f7296..b6b91a520 100644 --- a/packages/trpc/server/recipient-router/router.ts +++ b/packages/trpc/server/recipient-router/router.ts @@ -1,4 +1,5 @@ import { prepareCscRecipientSigning } from '@documenso/ee/server-only/signing/csc/prepare-recipient-signing'; +import { AppError } from '@documenso/lib/errors/app-error'; import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token'; import { rejectDocumentWithToken } from '@documenso/lib/server-only/document/reject-document-with-token'; import { createEnvelopeRecipients } from '@documenso/lib/server-only/recipient/create-envelope-recipients'; @@ -11,7 +12,6 @@ import { isTspEnvelope } from '@documenso/lib/types/signature-level'; import { unsafeBuildEnvelopeIdQuery } from '@documenso/lib/utils/envelope'; import { prisma } from '@documenso/prisma'; import { EnvelopeType } from '@prisma/client'; - import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../schema'; import { authenticatedProcedure, procedure, router } from '../trpc'; import { findRecipientSuggestionsRoute } from './find-recipient-suggestions'; @@ -590,47 +590,58 @@ export const recipientRouter = router({ .input(ZCompleteDocumentWithTokenMutationSchema) .output(ZCompleteDocumentWithTokenResponseSchema) .mutation(async ({ input, ctx }) => { - const { token, documentId, accessAuthOptions, nextSigner, recipientOverride } = input; + try { + const { token, documentId, accessAuthOptions, nextSigner, recipientOverride } = input; - ctx.logger.info({ - input: { - documentId, - }, - }); + ctx.logger.info({ + input: { + documentId, + }, + }); - // Branch on TSP envelopes before any SES side effects: TSP recipients - // can't complete via this route — they go through the CSC sync sign - // flow (`enterprise.csc.signEnvelope`). This route returns the redirect URL - // for the credential-scope OAuth round-trip. - const envelope = await prisma.envelope.findFirstOrThrow({ - where: { - ...unsafeBuildEnvelopeIdQuery({ type: 'documentId', id: documentId }, EnvelopeType.DOCUMENT), - recipients: { some: { token } }, - }, - select: { signatureLevel: true, internalVersion: true }, - }); + // Branch on TSP envelopes before any SES side effects: TSP recipients + // can't complete via this route — they go through the CSC sync sign + // flow (`enterprise.csc.signEnvelope`). This route returns the redirect URL + // for the credential-scope OAuth round-trip. + const envelope = await prisma.envelope.findFirstOrThrow({ + where: { + ...unsafeBuildEnvelopeIdQuery({ type: 'documentId', id: documentId }, EnvelopeType.DOCUMENT), + recipients: { some: { token } }, + }, + select: { signatureLevel: true, internalVersion: true }, + }); - if (isTspEnvelope(envelope)) { - return await prepareCscRecipientSigning({ - recipientToken: token, + if (isTspEnvelope(envelope)) { + return await prepareCscRecipientSigning({ + recipientToken: token, + requestMetadata: ctx.metadata.requestMetadata, + }); + } + + await completeDocumentWithToken({ + token, + id: { + type: 'documentId', + id: documentId, + }, + accessAuthOptions, + nextSigner, + recipientOverride, + userId: ctx.user?.id, requestMetadata: ctx.metadata.requestMetadata, }); + + return { status: 'SIGNED' as const }; + } catch (err) { + // Log the error for debugging purposes. + ctx.logger.error({ + message: 'Error completing document with token', + error: err instanceof AppError ? `[${err.code}]: ${err.message}` : err, + }); + + // Rethrow the error so that the client receives the appropriate error response. + throw err; } - - await completeDocumentWithToken({ - token, - id: { - type: 'documentId', - id: documentId, - }, - accessAuthOptions, - nextSigner, - recipientOverride, - userId: ctx.user?.id, - requestMetadata: ctx.metadata.requestMetadata, - }); - - return { status: 'SIGNED' as const }; }), /** From f0ab7c112e3c39656b0153b67fbf25fd9616e96f Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Wed, 5 Aug 2026 11:29:38 +1000 Subject: [PATCH 16/27] fix: add more logging for errors on sign or complete (#3151) --- packages/trpc/server/field-router/router.ts | 9 +++++++-- packages/trpc/server/recipient-router/router.ts | 5 ++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/trpc/server/field-router/router.ts b/packages/trpc/server/field-router/router.ts index 4d14e9de7..2028c37f6 100644 --- a/packages/trpc/server/field-router/router.ts +++ b/packages/trpc/server/field-router/router.ts @@ -636,9 +636,12 @@ export const fieldRouter = router({ // Log the error for debugging purposes. ctx.logger.error({ message: 'Error signing field with token', - error: err instanceof AppError ? `[${err.code}]: ${err.message}` : err, + error: err instanceof AppError ? `[${err.code}]: ${err.message}` : String(err), }); + // Raw console.log incase we're somehow deailing with a funky error object that doesn't serialize well. + console.log('Error signing field with token', err); + // Rethrow the error so that the client receives the appropriate error response. throw err; } @@ -668,9 +671,11 @@ export const fieldRouter = router({ // Log the error for debugging purposes. ctx.logger.error({ message: 'Error removing signed field with token', - error: err instanceof AppError ? `[${err.code}]: ${err.message}` : err, + error: err instanceof AppError ? `[${err.code}]: ${err.message}` : String(err), }); + console.log('Error removing signed field with token', err); + // Rethrow the error so that the client receives the appropriate error response. throw err; } diff --git a/packages/trpc/server/recipient-router/router.ts b/packages/trpc/server/recipient-router/router.ts index b6b91a520..e3ba84700 100644 --- a/packages/trpc/server/recipient-router/router.ts +++ b/packages/trpc/server/recipient-router/router.ts @@ -636,9 +636,12 @@ export const recipientRouter = router({ // Log the error for debugging purposes. ctx.logger.error({ message: 'Error completing document with token', - error: err instanceof AppError ? `[${err.code}]: ${err.message}` : err, + error: err instanceof AppError ? `[${err.code}]: ${err.message}` : String(err), }); + // Raw console.log incase we're somehow dealing with a funky error object that doesn't serialize well. + console.log('Error completing document with token', err); + // Rethrow the error so that the client receives the appropriate error response. throw err; } From d6cf3fec4bcbb1bac8608b64c4fcc71659cd68d4 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Sun, 9 Aug 2026 16:00:55 +1000 Subject: [PATCH 17/27] feat: unify settings (#3128) --- .../document-preferences-reset-dialog.tsx | 19 - .../dialogs/team-email-delete-dialog.tsx | 35 +- .../dialogs/team-email-update-dialog.tsx | 7 +- .../forms/branding-preferences-form.tsx | 57 +- .../forms/certificate-preferences-form.tsx | 169 ++++++ .../forms/document-preferences-form.tsx | 395 +++---------- .../forms/email-preferences-form.tsx | 117 +++- .../components/forms/form-sticky-save-bar.tsx | 26 +- .../components/forms/inheritable-field.tsx | 51 ++ .../forms/reminder-preferences-form.tsx | 134 +++++ .../app/components/general/app-header.tsx | 26 +- .../components/general/app-nav-desktop.tsx | 5 +- .../app/components/general/app-nav-mobile.tsx | 5 +- .../app/components/general/billing-plans.tsx | 1 - .../app/components/general/menu-switcher.tsx | 104 ---- .../components/general/org-menu-switcher.tsx | 48 +- .../organisation-billing-portal-button.tsx | 8 +- .../components/general/settings-header.tsx | 6 +- .../general/settings-nav-desktop.tsx | 137 ----- .../general/settings-nav-mobile.tsx | 144 ----- .../general/settings-org-switcher.tsx | 200 +++++++ .../general/settings-scope-breadcrumb.tsx | 58 ++ .../general/settings-team-switcher.tsx | 166 ++++++ .../general/teams/team-email-dropdown.tsx | 94 --- .../general/unified-settings-layout.tsx | 211 +++++++ .../unified-settings-sidebar-mobile.tsx | 165 ++++++ .../general/unified-settings-sidebar.tsx | 275 +++++++++ .../tables/user-organisations-table.tsx | 24 +- .../app/routes/_authenticated+/_layout.tsx | 30 +- .../routes/_authenticated+/admin+/claims.tsx | 2 +- .../admin+/email-transports._index.tsx | 2 +- .../admin+/organisations.$id.tsx | 10 +- .../_authenticated+/o.$orgUrl._layout.tsx | 12 +- .../o.$orgUrl.settings._layout.tsx | 180 +----- .../o.$orgUrl.settings.branding.tsx | 2 +- .../o.$orgUrl.settings.certificates.tsx | 80 +++ .../o.$orgUrl.settings.document.tsx | 15 +- .../o.$orgUrl.settings.email-domains.$id.tsx | 2 +- ....$orgUrl.settings.email-domains._index.tsx | 14 +- .../o.$orgUrl.settings.email.tsx | 5 +- .../o.$orgUrl.settings.general.tsx | 28 +- .../o.$orgUrl.settings.groups.$id.tsx | 6 +- .../o.$orgUrl.settings.groups._index.tsx | 1 + .../o.$orgUrl.settings.members.tsx | 6 +- .../o.$orgUrl.settings.reminders.tsx | 76 +++ .../o.$orgUrl.settings.sso.tsx | 3 +- .../o.$orgUrl.settings.teams.tsx | 2 +- .../_dynamic_personal_routes+/_layout.tsx | 54 -- .../billing-personal.tsx | 5 - .../_dynamic_personal_routes+/branding.tsx | 5 - .../_dynamic_personal_routes+/document.tsx | 5 - .../_dynamic_personal_routes+/email.tsx | 5 - .../public-profile.tsx | 5 - .../_dynamic_personal_routes+/tokens.tsx | 5 - .../webhooks.$id._index.tsx | 5 - .../webhooks._index.tsx | 5 - .../_authenticated+/settings+/_layout.tsx | 35 +- .../_authenticated+/settings+/billing.tsx | 1 + .../settings+/organisations.tsx | 1 + .../_authenticated+/settings+/profile.tsx | 2 - .../settings+/security._index.tsx | 8 +- .../settings+/security.activity.tsx | 2 +- .../settings+/security.passkeys.tsx | 2 +- .../_authenticated+/t.$teamUrl+/_layout.tsx | 6 +- .../t.$teamUrl+/documents.$id.logs.tsx | 9 +- .../t.$teamUrl+/settings._index.tsx | 163 +----- .../t.$teamUrl+/settings._layout.tsx | 135 +---- .../t.$teamUrl+/settings.branding.tsx | 2 +- .../t.$teamUrl+/settings.certificates.tsx | 76 +++ .../t.$teamUrl+/settings.document.tsx | 18 +- .../t.$teamUrl+/settings.email.tsx | 11 +- .../t.$teamUrl+/settings.general.tsx | 281 +++++++++ .../t.$teamUrl+/settings.groups.tsx | 2 +- .../t.$teamUrl+/settings.members.tsx | 2 +- .../t.$teamUrl+/settings.public-profile.tsx | 5 +- .../t.$teamUrl+/settings.reminders.tsx | 76 +++ .../t.$teamUrl+/settings.tokens.tsx | 1 + .../settings.webhooks.$id._index.tsx | 1 + .../t.$teamUrl+/settings.webhooks._index.tsx | 1 + apps/remix/app/routes/_index.tsx | 3 +- apps/remix/app/routes/_profile+/p.$url.tsx | 19 +- .../organisation.sso.confirmation.$token.tsx | 2 +- .../team.verify.email.$token.tsx | 205 ++++--- apps/remix/app/routes/api+/preferred-team.tsx | 21 + apps/remix/server/middleware.ts | 3 +- .../update-organisation-member-role.spec.ts | 6 +- .../envelope-expiration-settings.spec.ts | 20 +- .../envelope-expiration-signing.spec.ts | 83 ++- .../include-document-certificate.spec.ts | 38 +- .../organisation-team-preferences.spec.ts | 24 +- .../public-profiles/public-profiles.spec.ts | 31 + .../settings/preferred-team-cookie.spec.ts | 82 +++ .../e2e/settings/unified-settings.spec.ts | 554 ++++++++++++++++++ .../app-tests/e2e/teams/manage-team.spec.ts | 3 +- .../app-tests/e2e/teams/team-email.spec.ts | 35 +- .../e2e/teams/team-settings-save-bar.spec.ts | 10 +- .../webhooks/webhook-secret-access.spec.ts | 74 +++ .../hooks/use-child-route-flags.ts | 55 ++ packages/lib/constants/cookies.ts | 1 + .../team/get-team-email-by-email.ts | 37 -- packages/lib/server-only/team/get-teams.ts | 4 +- packages/lib/server-only/user/verify-email.ts | 12 +- .../webhooks/get-webhooks-by-team-id.ts | 34 +- packages/lib/utils/settings-nav.ts | 298 ++++++++++ packages/lib/utils/settings-switcher.ts | 57 ++ packages/lib/vitest.config.ts | 3 + .../trpc/server/document-router/find-inbox.ts | 16 +- .../enterprise-router/create-subscription.ts | 6 +- .../create-subscription.types.ts | 1 - .../enterprise-router/manage-subscription.ts | 6 +- .../manage-subscription.types.ts | 1 - .../envelope-router/sign-envelope-field.ts | 7 + .../update-organisation-settings.ts | 11 +- .../complete-team-email-verification.ts | 87 +++ .../complete-team-email-verification.types.ts | 11 + packages/trpc/server/team-router/router.ts | 30 +- .../team-router/update-team-settings.ts | 11 +- packages/ui/primitives/avatar.tsx | 2 +- .../ui/primitives/document-upload-button.tsx | 8 +- packages/ui/styles/theme.css | 32 + 120 files changed, 4181 insertions(+), 1859 deletions(-) create mode 100644 apps/remix/app/components/forms/certificate-preferences-form.tsx create mode 100644 apps/remix/app/components/forms/inheritable-field.tsx create mode 100644 apps/remix/app/components/forms/reminder-preferences-form.tsx delete mode 100644 apps/remix/app/components/general/menu-switcher.tsx delete mode 100644 apps/remix/app/components/general/settings-nav-desktop.tsx delete mode 100644 apps/remix/app/components/general/settings-nav-mobile.tsx create mode 100644 apps/remix/app/components/general/settings-org-switcher.tsx create mode 100644 apps/remix/app/components/general/settings-scope-breadcrumb.tsx create mode 100644 apps/remix/app/components/general/settings-team-switcher.tsx delete mode 100644 apps/remix/app/components/general/teams/team-email-dropdown.tsx create mode 100644 apps/remix/app/components/general/unified-settings-layout.tsx create mode 100644 apps/remix/app/components/general/unified-settings-sidebar-mobile.tsx create mode 100644 apps/remix/app/components/general/unified-settings-sidebar.tsx create mode 100644 apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.certificates.tsx create mode 100644 apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.reminders.tsx delete mode 100644 apps/remix/app/routes/_authenticated+/settings+/_dynamic_personal_routes+/_layout.tsx delete mode 100644 apps/remix/app/routes/_authenticated+/settings+/_dynamic_personal_routes+/billing-personal.tsx delete mode 100644 apps/remix/app/routes/_authenticated+/settings+/_dynamic_personal_routes+/branding.tsx delete mode 100644 apps/remix/app/routes/_authenticated+/settings+/_dynamic_personal_routes+/document.tsx delete mode 100644 apps/remix/app/routes/_authenticated+/settings+/_dynamic_personal_routes+/email.tsx delete mode 100644 apps/remix/app/routes/_authenticated+/settings+/_dynamic_personal_routes+/public-profile.tsx delete mode 100644 apps/remix/app/routes/_authenticated+/settings+/_dynamic_personal_routes+/tokens.tsx delete mode 100644 apps/remix/app/routes/_authenticated+/settings+/_dynamic_personal_routes+/webhooks.$id._index.tsx delete mode 100644 apps/remix/app/routes/_authenticated+/settings+/_dynamic_personal_routes+/webhooks._index.tsx create mode 100644 apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.certificates.tsx create mode 100644 apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.general.tsx create mode 100644 apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.reminders.tsx create mode 100644 apps/remix/app/routes/api+/preferred-team.tsx create mode 100644 packages/app-tests/e2e/settings/preferred-team-cookie.spec.ts create mode 100644 packages/app-tests/e2e/settings/unified-settings.spec.ts create mode 100644 packages/app-tests/e2e/webhooks/webhook-secret-access.spec.ts create mode 100644 packages/lib/client-only/hooks/use-child-route-flags.ts create mode 100644 packages/lib/constants/cookies.ts delete mode 100644 packages/lib/server-only/team/get-team-email-by-email.ts create mode 100644 packages/lib/utils/settings-nav.ts create mode 100644 packages/lib/utils/settings-switcher.ts create mode 100644 packages/trpc/server/team-router/complete-team-email-verification.ts create mode 100644 packages/trpc/server/team-router/complete-team-email-verification.types.ts diff --git a/apps/remix/app/components/dialogs/document-preferences-reset-dialog.tsx b/apps/remix/app/components/dialogs/document-preferences-reset-dialog.tsx index 9adebb36e..ed7184ef7 100644 --- a/apps/remix/app/components/dialogs/document-preferences-reset-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-preferences-reset-dialog.tsx @@ -18,7 +18,6 @@ export type DocumentPreferencesResetDialogProps = { onReset: () => Promise; showAiFeatures?: boolean; showDocumentVisibility?: boolean; - showIncludeSenderDetails?: boolean; }; export const DocumentPreferencesResetDialog = ({ @@ -26,7 +25,6 @@ export const DocumentPreferencesResetDialog = ({ onReset, showAiFeatures = false, showDocumentVisibility = false, - showIncludeSenderDetails = false, }: DocumentPreferencesResetDialogProps) => { const [open, setOpen] = useState(false); const [isResetting, setIsResetting] = useState(false); @@ -92,29 +90,12 @@ export const DocumentPreferencesResetDialog = ({
  • Default signature settings
  • - {showIncludeSenderDetails && ( -
  • - Send on behalf of team -
  • - )} -
  • - Include the signing certificate in the document -
  • -
  • - Include the audit logs in the document -
  • Default recipients
  • Delegate document ownership
  • -
  • - Default envelope expiration -
  • -
  • - Default signing reminders -
  • {showAiFeatures && (
  • AI features diff --git a/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx b/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx index 7c08cf7d3..147cf3b40 100644 --- a/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx @@ -17,28 +17,25 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { Prisma } from '@prisma/client'; +import type { Team, TeamEmail, TeamEmailVerification } from '@prisma/client'; import { useState } from 'react'; import { useRevalidator } from 'react-router'; export type TeamEmailDeleteDialogProps = { trigger?: React.ReactNode; teamName: string; - team: Prisma.TeamGetPayload<{ - include: { - teamEmail: true; - emailVerification: { - select: { - expiresAt: true; - name: true; - email: true; - }; - }; - }; - }>; + team: Pick; + teamEmail: Pick | null; + emailVerification: Pick | null; }; -export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDeleteDialogProps) => { +export const TeamEmailDeleteDialog = ({ + trigger, + teamName, + team, + teamEmail, + emailVerification, +}: TeamEmailDeleteDialogProps) => { const [open, setOpen] = useState(false); const { _ } = useLingui(); @@ -83,11 +80,11 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele }); const onRemove = async () => { - if (team.teamEmail) { + if (teamEmail) { await deleteTeamEmail({ teamId: team.id }); } - if (team.emailVerification) { + if (emailVerification) { await deleteTeamEmailVerification({ teamId: team.id }); } @@ -121,13 +118,13 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele - {team.teamEmail?.name || team.emailVerification?.name} + {teamEmail?.name || emailVerification?.name} } - secondaryText={{team.teamEmail?.email || team.emailVerification?.email}} + secondaryText={{teamEmail?.email || emailVerification?.email}} /> diff --git a/apps/remix/app/components/dialogs/team-email-update-dialog.tsx b/apps/remix/app/components/dialogs/team-email-update-dialog.tsx index 3fbddc3c2..449d5ec36 100644 --- a/apps/remix/app/components/dialogs/team-email-update-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-email-update-dialog.tsx @@ -23,7 +23,8 @@ import { useRevalidator } from 'react-router'; import type { z } from 'zod'; export type TeamEmailUpdateDialogProps = { - teamEmail: TeamEmail; + teamId: number; + teamEmail: Pick; trigger?: React.ReactNode; } & Omit; @@ -33,7 +34,7 @@ const ZUpdateTeamEmailFormSchema = ZUpdateTeamEmailMutationSchema.pick({ type TUpdateTeamEmailFormSchema = z.infer; -export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmailUpdateDialogProps) => { +export const TeamEmailUpdateDialog = ({ teamId, teamEmail, trigger, ...props }: TeamEmailUpdateDialogProps) => { const [open, setOpen] = useState(false); const { t } = useLingui(); @@ -53,7 +54,7 @@ export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmai const onFormSubmit = async ({ name }: TUpdateTeamEmailFormSchema) => { try { await updateTeamEmail({ - teamId: teamEmail.teamId, + teamId, data: { name, }, diff --git a/apps/remix/app/components/forms/branding-preferences-form.tsx b/apps/remix/app/components/forms/branding-preferences-form.tsx index e556ff6cf..1cb5be35d 100644 --- a/apps/remix/app/components/forms/branding-preferences-form.tsx +++ b/apps/remix/app/components/forms/branding-preferences-form.tsx @@ -29,6 +29,7 @@ import { useOptionalCurrentTeam } from '~/providers/team'; import { useCspNonce } from '~/utils/nonce'; import { FormStickySaveBar } from './form-sticky-save-bar'; +import { InheritableField } from './inheritable-field'; const ZBrandingPreferencesFormSchema = z.object({ brandingEnabled: z.boolean().nullable(), @@ -210,11 +211,13 @@ export function BrandingPreferencesForm({ control={form.control} name="brandingEnabled" render={({ field }) => ( - - - Enable Custom Branding - - + Enable Custom Branding} + testId="branding-enabled" + > @@ -372,7 +379,7 @@ export function BrandingPreferencesForm({ )} - + )} /> @@ -380,11 +387,13 @@ export function BrandingPreferencesForm({ control={form.control} name="brandingCompanyDetails" render={({ field }) => ( - - - Brand Details - - + Brand Details} + testId="branding-company-details" + >