From 48fb066b9a3e9904d52a58bfc36cfead4379c974 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 19 Mar 2026 14:03:30 +1100 Subject: [PATCH] feat: allow editing pending envelope titles (#2604) --- .../envelope-editor-fields-page.tsx | 8 +- .../envelope-editor-header.tsx | 15 +- .../envelope-editor-upload-page.tsx | 23 +- .../e2e/api/v2/update-envelope-items.spec.ts | 519 ++++++++++++++++++ .../envelope-editor-v2/envelope-items.spec.ts | 72 +++ .../envelope-item/update-envelope-items.ts | 65 ++- .../server-only/envelope/update-envelope.ts | 8 +- packages/lib/types/document-audit-logs.ts | 19 + packages/lib/utils/document-audit-logs.ts | 8 + packages/lib/utils/envelope.ts | 65 ++- .../update-embedding-envelope.ts | 41 +- .../envelope-router/create-envelope-items.ts | 6 +- .../envelope-router/delete-envelope-item.ts | 6 +- .../replace-envelope-item-pdf.ts | 6 +- .../envelope-router/update-envelope-items.ts | 47 +- packages/trpc/server/trpc.ts | 12 +- 16 files changed, 860 insertions(+), 60 deletions(-) create mode 100644 packages/app-tests/e2e/api/v2/update-envelope-items.spec.ts 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 7403ae4b3..366a34854 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 @@ -28,7 +28,7 @@ import { type TSignatureFieldMeta, type TTextFieldMeta, } from '@documenso/lib/types/field-meta'; -import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope'; +import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope'; import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; import { cn } from '@documenso/ui/lib/utils'; @@ -88,8 +88,8 @@ export const EnvelopeEditorFieldsPage = () => { const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false); const { revalidate } = useRevalidator(); - const canItemsBeModified = useMemo( - () => canEnvelopeItemsBeModified(envelope, envelope.recipients), + const envelopeItemPermissions = useMemo( + () => getEnvelopeItemPermissions(envelope, envelope.recipients), [envelope, envelope.recipients], ); @@ -171,7 +171,7 @@ export const EnvelopeEditorFieldsPage = () => { renderItemAction={ editorConfig.envelopeItems !== null && editorConfig.envelopeItems.allowReplace && - canItemsBeModified + envelopeItemPermissions.canFileBeChanged ? (item) => (
getEnvelopeItemPermissions(envelope, envelope.recipients), + [envelope, envelope.recipients], + ); + const handleCreateEmbeddedEnvelope = async () => { const latestEnvelope = await flushAutosave(); @@ -81,7 +91,8 @@ export default function EnvelopeEditorHeader() {
{ updateEnvelope({ 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 255305d86..2d02db008 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 @@ -4,7 +4,6 @@ import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd'; import type { DropResult } from '@hello-pangea/dnd'; import { msg, plural } from '@lingui/core/macro'; import { Trans, useLingui } from '@lingui/react/macro'; -import { DocumentStatus } from '@prisma/client'; import { FileWarningIcon, GripVerticalIcon, Loader2Icon, PencilIcon, XIcon } from 'lucide-react'; import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from 'react-dropzone'; @@ -17,7 +16,7 @@ import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor'; import { nanoid } from '@documenso/lib/universal/id'; import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions'; import { PRESIGNED_ENVELOPE_ITEM_ID_PREFIX } from '@documenso/lib/utils/embed-config'; -import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope'; +import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope'; import { trpc } from '@documenso/trpc/react'; import type { TCreateEnvelopeItemsPayload } from '@documenso/trpc/server/envelope-router/create-envelope-items.types'; import type { TReplaceEnvelopeItemPdfPayload } from '@documenso/trpc/server/envelope-router/replace-envelope-item-pdf.types'; @@ -137,6 +136,11 @@ export const EnvelopeEditorUploadPage = () => { }, }); + const envelopeItemPermissions = useMemo( + () => getEnvelopeItemPermissions(envelope, envelope.recipients), + [envelope, envelope.recipients], + ); + const { mutateAsync: replaceEnvelopeItemPdf } = trpc.envelope.item.replacePdf.useMutation({ onSuccess: ({ data, fields }) => { // Update the envelope item with the new documentDataId. @@ -155,11 +159,6 @@ export const EnvelopeEditorUploadPage = () => { }, }); - const canItemsBeModified = useMemo( - () => canEnvelopeItemsBeModified(envelope, envelope.recipients), - [envelope, envelope.recipients], - ); - const onFileDrop = async (files: File[]) => { const newUploadingFiles: (LocalFile & { file: File; @@ -418,7 +417,7 @@ export const EnvelopeEditorUploadPage = () => { }; const dropzoneDisabledMessage = useMemo(() => { - if (!canItemsBeModified) { + if (!envelopeItemPermissions.canFileBeChanged) { return msg`Cannot upload items after the document has been sent`; } @@ -509,8 +508,8 @@ export const EnvelopeEditorUploadPage = () => { key={localFile.id} isDragDisabled={ isCreatingEnvelopeItems || + !envelopeItemPermissions.canOrderBeChanged || localFile.isReplacing || - !canItemsBeModified || !uploadConfig?.allowConfigureOrder } draggableId={localFile.id} @@ -541,7 +540,7 @@ export const EnvelopeEditorUploadPage = () => { {localFile.envelopeItemId !== null ? ( { )} {localFile.envelopeItemId && - canItemsBeModified && + envelopeItemPermissions.canFileBeChanged && uploadConfig?.allowReplace && ( ) : ( { + const payload: TCreateEnvelopePayload = { + type: EnvelopeType.DOCUMENT, + title: 'Update Items Test', + }; + + const formData = new FormData(); + formData.append('payload', JSON.stringify(payload)); + + const pdfData = fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')); + formData.append('files', new File([pdfData], 'test.pdf', { type: 'application/pdf' })); + + const res = await request.post(`${baseUrl}/envelope/create`, { + headers: { Authorization: `Bearer ${authToken}` }, + multipart: formData, + }); + + expect(res.ok()).toBeTruthy(); + + return (await res.json()) as TCreateEnvelopeResponse; +}; + +const getEnvelope = async (request: APIRequestContext, authToken: string, envelopeId: string) => { + const res = await request.get(`${baseUrl}/envelope/${envelopeId}`, { + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(res.ok()).toBeTruthy(); + + return (await res.json()) as TGetEnvelopeResponse; +}; + +/** + * Transition an envelope from DRAFT to PENDING by adding a recipient with a + * signature field and distributing. + */ +const distributeEnvelope = async ( + request: APIRequestContext, + authToken: string, + envelopeId: string, +) => { + const recipientEmail = `signer-${Date.now()}@test.documenso.com`; + + // Create a SIGNER recipient. + const recipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, { + headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' }, + data: { + envelopeId, + data: [ + { + email: recipientEmail, + name: 'Test Signer', + role: RecipientRole.SIGNER, + accessAuth: [], + actionAuth: [], + }, + ], + } satisfies TCreateEnvelopeRecipientsRequest, + }); + + expect(recipientsRes.ok()).toBeTruthy(); + + const recipients = (await recipientsRes.json()).data; + + // Resolve the envelope item ID. + const envelope = await getEnvelope(request, authToken, envelopeId); + const envelopeItemId = envelope.envelopeItems[0].id; + + // Create a SIGNATURE field. + const fieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, { + headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' }, + data: { + envelopeId, + data: [ + { + recipientId: recipients[0].id, + envelopeItemId, + type: FieldType.SIGNATURE, + page: 1, + positionX: 100, + positionY: 100, + width: 50, + height: 50, + }, + ], + }, + }); + + expect(fieldsRes.ok()).toBeTruthy(); + + // Distribute. + const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, { + headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' }, + data: { + envelopeId, + } satisfies TDistributeEnvelopeRequest, + }); + + expect(distributeRes.ok()).toBeTruthy(); +}; + +const updateEnvelopeItems = async ( + request: APIRequestContext, + authToken: string, + payload: TUpdateEnvelopeItemsRequest, +) => { + return request.post(`${baseUrl}/envelope/item/update-many`, { + headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' }, + data: payload, + }); +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test.describe('Update envelope items', () => { + let user: User; + let team: Team; + let token: string; + + test.beforeEach(async () => { + ({ user, team } = await seedUser()); + ({ token } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'test-update-items', + expiresIn: null, + })); + }); + + // ----------------------------------------------------------------------- + // DRAFT envelope — full edit allowed + // ----------------------------------------------------------------------- + + test('should allow updating item title on a DRAFT envelope', async ({ request }) => { + const envelope = await createEnvelope(request, token); + const envelopeData = await getEnvelope(request, token, envelope.id); + const envelopeItem = envelopeData.envelopeItems[0]; + + const res = await updateEnvelopeItems(request, token, { + envelopeId: envelope.id, + data: [{ envelopeItemId: envelopeItem.id, title: 'New Draft Title' }], + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const dbItem = await prisma.envelopeItem.findUniqueOrThrow({ + where: { id: envelopeItem.id }, + }); + + expect(dbItem.title).toBe('New Draft Title'); + }); + + test('should allow updating item order on a DRAFT envelope', async ({ request }) => { + const envelope = await createEnvelope(request, token); + const envelopeData = await getEnvelope(request, token, envelope.id); + const envelopeItem = envelopeData.envelopeItems[0]; + + const res = await updateEnvelopeItems(request, token, { + envelopeId: envelope.id, + data: [{ envelopeItemId: envelopeItem.id, order: 5 }], + }); + + expect(res.ok()).toBeTruthy(); + + const dbItem = await prisma.envelopeItem.findUniqueOrThrow({ + where: { id: envelopeItem.id }, + }); + + expect(dbItem.order).toBe(5); + }); + + // ----------------------------------------------------------------------- + // PENDING envelope — title-only edit allowed + // ----------------------------------------------------------------------- + + test('should allow updating item title on a PENDING envelope', async ({ request }) => { + const envelope = await createEnvelope(request, token); + await distributeEnvelope(request, token, envelope.id); + + const envelopeData = await getEnvelope(request, token, envelope.id); + const envelopeItem = envelopeData.envelopeItems[0]; + + const res = await updateEnvelopeItems(request, token, { + envelopeId: envelope.id, + data: [{ envelopeItemId: envelopeItem.id, title: 'Updated Pending Title' }], + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const dbItem = await prisma.envelopeItem.findUniqueOrThrow({ + where: { id: envelopeItem.id }, + }); + + expect(dbItem.title).toBe('Updated Pending Title'); + }); + + test('should allow title-only update when order matches existing on a PENDING envelope', async ({ + request, + }) => { + const envelope = await createEnvelope(request, token); + await distributeEnvelope(request, token, envelope.id); + + const envelopeData = await getEnvelope(request, token, envelope.id); + const envelopeItem = envelopeData.envelopeItems[0]; + + // Send the same order value that already exists — this should be treated as title-only. + const res = await updateEnvelopeItems(request, token, { + envelopeId: envelope.id, + data: [ + { + envelopeItemId: envelopeItem.id, + title: 'Title With Same Order', + order: envelopeItem.order, + }, + ], + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const dbItem = await prisma.envelopeItem.findUniqueOrThrow({ + where: { id: envelopeItem.id }, + }); + + expect(dbItem.title).toBe('Title With Same Order'); + }); + + test('should create an ENVELOPE_ITEM_UPDATED audit log when updating item title on PENDING envelope', async ({ + request, + }) => { + const envelope = await createEnvelope(request, token); + await distributeEnvelope(request, token, envelope.id); + + const envelopeData = await getEnvelope(request, token, envelope.id); + const envelopeItem = envelopeData.envelopeItems[0]; + const originalTitle = envelopeItem.title; + + const res = await updateEnvelopeItems(request, token, { + envelopeId: envelope.id, + data: [{ envelopeItemId: envelopeItem.id, title: 'Audited Title' }], + }); + + expect(res.ok()).toBeTruthy(); + + const auditLog = await prisma.documentAuditLog.findFirst({ + where: { + envelopeId: envelope.id, + type: 'ENVELOPE_ITEM_UPDATED', + }, + orderBy: { createdAt: 'desc' }, + }); + + expect(auditLog).not.toBeNull(); + + const auditData = auditLog!.data as Record; + + expect(auditData.envelopeItemId).toBe(envelopeItem.id); + + const changes = auditData.changes as Array<{ field: string; from: string; to: string }>; + + expect(changes).toHaveLength(1); + expect(changes[0].field).toBe('title'); + expect(changes[0].from).toBe(originalTitle); + expect(changes[0].to).toBe('Audited Title'); + }); + + // ----------------------------------------------------------------------- + // PENDING envelope — order change blocked + // ----------------------------------------------------------------------- + + test('should reject order change on a PENDING envelope', async ({ request }) => { + const envelope = await createEnvelope(request, token); + await distributeEnvelope(request, token, envelope.id); + + const envelopeData = await getEnvelope(request, token, envelope.id); + const envelopeItem = envelopeData.envelopeItems[0]; + + const res = await updateEnvelopeItems(request, token, { + envelopeId: envelope.id, + data: [{ envelopeItemId: envelopeItem.id, order: 99 }], + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(400); + }); + + test('should reject combined title and order change on a PENDING envelope', async ({ + request, + }) => { + const envelope = await createEnvelope(request, token); + await distributeEnvelope(request, token, envelope.id); + + const envelopeData = await getEnvelope(request, token, envelope.id); + const envelopeItem = envelopeData.envelopeItems[0]; + + const res = await updateEnvelopeItems(request, token, { + envelopeId: envelope.id, + data: [ + { + envelopeItemId: envelopeItem.id, + title: 'Should Not Save', + order: 99, + }, + ], + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(400); + + // Verify title was NOT changed. + const dbItem = await prisma.envelopeItem.findUniqueOrThrow({ + where: { id: envelopeItem.id }, + }); + + expect(dbItem.title).not.toBe('Should Not Save'); + }); + + // ----------------------------------------------------------------------- + // COMPLETED envelope — all edits blocked + // ----------------------------------------------------------------------- + + test('should reject title update on a COMPLETED envelope', async ({ request }) => { + const envelope = await createEnvelope(request, token); + await distributeEnvelope(request, token, envelope.id); + + // Transition to COMPLETED directly via database. + await prisma.envelope.update({ + where: { id: envelope.id }, + data: { status: DocumentStatus.COMPLETED, completedAt: new Date() }, + }); + + const envelopeData = await getEnvelope(request, token, envelope.id); + const envelopeItem = envelopeData.envelopeItems[0]; + + const res = await updateEnvelopeItems(request, token, { + envelopeId: envelope.id, + data: [{ envelopeItemId: envelopeItem.id, title: 'Should Not Save' }], + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(400); + + // Verify title was NOT changed. + const dbItem = await prisma.envelopeItem.findUniqueOrThrow({ + where: { id: envelopeItem.id }, + }); + + expect(dbItem.title).not.toBe('Should Not Save'); + }); + + test('should reject order update on a COMPLETED envelope', async ({ request }) => { + const envelope = await createEnvelope(request, token); + await distributeEnvelope(request, token, envelope.id); + + await prisma.envelope.update({ + where: { id: envelope.id }, + data: { status: DocumentStatus.COMPLETED, completedAt: new Date() }, + }); + + const envelopeData = await getEnvelope(request, token, envelope.id); + const envelopeItem = envelopeData.envelopeItems[0]; + + const res = await updateEnvelopeItems(request, token, { + envelopeId: envelope.id, + data: [{ envelopeItemId: envelopeItem.id, order: 99 }], + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(400); + }); + + // ----------------------------------------------------------------------- + // REJECTED envelope — all edits blocked + // ----------------------------------------------------------------------- + + test('should reject title update on a REJECTED envelope', async ({ request }) => { + const envelope = await createEnvelope(request, token); + await distributeEnvelope(request, token, envelope.id); + + // Transition to REJECTED directly via database. + await prisma.envelope.update({ + where: { id: envelope.id }, + data: { status: DocumentStatus.REJECTED }, + }); + + const envelopeData = await getEnvelope(request, token, envelope.id); + const envelopeItem = envelopeData.envelopeItems[0]; + + const res = await updateEnvelopeItems(request, token, { + envelopeId: envelope.id, + data: [{ envelopeItemId: envelopeItem.id, title: 'Should Not Save' }], + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(400); + + // Verify title was NOT changed. + const dbItem = await prisma.envelopeItem.findUniqueOrThrow({ + where: { id: envelopeItem.id }, + }); + + expect(dbItem.title).not.toBe('Should Not Save'); + }); + + test('should reject order update on a REJECTED envelope', async ({ request }) => { + const envelope = await createEnvelope(request, token); + await distributeEnvelope(request, token, envelope.id); + + await prisma.envelope.update({ + where: { id: envelope.id }, + data: { status: DocumentStatus.REJECTED }, + }); + + const envelopeData = await getEnvelope(request, token, envelope.id); + const envelopeItem = envelopeData.envelopeItems[0]; + + const res = await updateEnvelopeItems(request, token, { + envelopeId: envelope.id, + data: [{ envelopeItemId: envelopeItem.id, order: 99 }], + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(400); + }); + + // ----------------------------------------------------------------------- + // Deleted envelope — all edits blocked + // ----------------------------------------------------------------------- + + test('should reject title update on a deleted envelope', async ({ request }) => { + const envelope = await createEnvelope(request, token); + await distributeEnvelope(request, token, envelope.id); + + // Soft-delete the envelope. + await prisma.envelope.update({ + where: { id: envelope.id }, + data: { deletedAt: new Date() }, + }); + + const envelopeData = await getEnvelope(request, token, envelope.id); + const envelopeItem = envelopeData.envelopeItems[0]; + + const res = await updateEnvelopeItems(request, token, { + envelopeId: envelope.id, + data: [{ envelopeItemId: envelopeItem.id, title: 'Should Not Save' }], + }); + + expect(res.ok()).toBeFalsy(); + }); + + // ----------------------------------------------------------------------- + // Validation edge cases + // ----------------------------------------------------------------------- + + test('should reject an empty title on any envelope', async ({ request }) => { + const envelope = await createEnvelope(request, token); + const envelopeData = await getEnvelope(request, token, envelope.id); + const envelopeItem = envelopeData.envelopeItems[0]; + + const res = await updateEnvelopeItems(request, token, { + envelopeId: envelope.id, + data: [{ envelopeItemId: envelopeItem.id, title: '' }], + }); + + expect(res.ok()).toBeFalsy(); + }); + + test('should reject update for an envelope item that does not belong to the envelope', async ({ + request, + }) => { + const envelopeA = await createEnvelope(request, token); + const envelopeB = await createEnvelope(request, token); + + const envelopeBData = await getEnvelope(request, token, envelopeB.id); + const envelopeBItem = envelopeBData.envelopeItems[0]; + + // Try to update envelopeB's item via envelopeA's ID. + const res = await updateEnvelopeItems(request, token, { + envelopeId: envelopeA.id, + data: [{ envelopeItemId: envelopeBItem.id, title: 'Cross Envelope' }], + }); + + expect(res.ok()).toBeFalsy(); + }); +}); diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-items.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-items.spec.ts index bdf7f3679..bd89ca04b 100644 --- a/packages/app-tests/e2e/envelope-editor-v2/envelope-items.spec.ts +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-items.spec.ts @@ -4,7 +4,10 @@ import path from 'node:path'; import { nanoid } from '@documenso/lib/universal/id'; import { prisma } from '@documenso/prisma'; +import { seedPendingDocument } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; +import { apiSignin } from '../fixtures/authentication'; import { type TEnvelopeEditorSurface, clickEnvelopeEditorStep, @@ -314,3 +317,72 @@ test.describe('embedded edit', () => { expect(items[0].order).toBe(2); // Expect order 2 because deleting items does not drop the order of sequential items. }); }); + +test.describe('pending envelope title editing', () => { + test('edit envelope and item titles on a pending envelope', async ({ page }) => { + const recipientEmail = `recipient-${nanoid()}@test.documenso.com`; + const { user, team } = await seedUser(); + const pendingDocument = await seedPendingDocument(user, team.id, [recipientEmail], { + internalVersion: 2, + }); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents/${pendingDocument.id}/edit`, + }); + + // Verify the envelope editor loaded with the upload step visible. + await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible(); + + // Edit the envelope title in the header. + const envelopeTitleInput = page.locator('[data-testid="envelope-title-input"]'); + await expect(envelopeTitleInput).toBeEnabled(); + await envelopeTitleInput.fill('Updated Pending Title'); + + // Edit the envelope item title. + const itemTitleInput = getEnvelopeItemTitleInputs(page).first(); + await expect(itemTitleInput).toBeEnabled(); + await itemTitleInput.fill('Updated Item Title'); + + // Wait for debounced auto-save to persist by navigating away and back. + await clickEnvelopeEditorStep(page, 'addFields'); + await expect(page.getByText('Selected Recipient')).toBeVisible(); + await clickEnvelopeEditorStep(page, 'upload'); + await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible(); + + // Verify the titles persisted in the database. + const updatedEnvelope = await prisma.envelope.findFirstOrThrow({ + where: { id: pendingDocument.id }, + include: { + envelopeItems: true, + auditLogs: { + orderBy: { createdAt: 'desc' }, + }, + }, + }); + + expect(updatedEnvelope.title).toBe('Updated Pending Title'); + expect(updatedEnvelope.envelopeItems[0].title).toBe('Updated Item Title'); + + // Verify audit logs were created for both title changes. + const titleAuditLog = updatedEnvelope.auditLogs.find( + (log) => log.type === 'DOCUMENT_TITLE_UPDATED', + ); + expect(titleAuditLog).toBeDefined(); + + const itemAuditLog = updatedEnvelope.auditLogs.find( + (log) => log.type === 'ENVELOPE_ITEM_UPDATED', + ); + expect(itemAuditLog).toBeDefined(); + + // Verify the upload dropzone is still disabled for pending envelopes. + const dropzoneMessage = page.getByText('Cannot upload items after the document has been sent'); + await expect(dropzoneMessage).toBeVisible(); + + // Drag handles are still rendered but dragging is disabled via isDragDisabled + // when canItemsBeModified is false. Verify at least that the handle is present + // but we cannot programmatically assert isDragDisabled from the DOM. + await expect(getEnvelopeItemDragHandles(page)).toHaveCount(1); + }); +}); diff --git a/packages/lib/server-only/envelope-item/update-envelope-items.ts b/packages/lib/server-only/envelope-item/update-envelope-items.ts index 4e2369a57..787aef29d 100644 --- a/packages/lib/server-only/envelope-item/update-envelope-items.ts +++ b/packages/lib/server-only/envelope-item/update-envelope-items.ts @@ -1,20 +1,34 @@ +import type { EnvelopeItem, EnvelopeType } from '@prisma/client'; + +import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; +import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { prisma } from '@documenso/prisma'; type UnsafeUpdateEnvelopeItemsOptions = { envelopeId: string; + envelopeType: EnvelopeType; + existingEnvelopeItems: Pick[]; data: { envelopeItemId: string; order?: number; title?: string; }[]; + user: { + name: string | null; + email: string; + }; + apiRequestMetadata: ApiRequestMetadata; }; export const UNSAFE_updateEnvelopeItems = async ({ envelopeId, + envelopeType, + existingEnvelopeItems, data, + user, + apiRequestMetadata, }: UnsafeUpdateEnvelopeItemsOptions) => { - // Todo: Envelope [AUDIT_LOGS] - const updatedEnvelopeItems = await Promise.all( data.map(async ({ envelopeItemId, order, title }) => prisma.envelopeItem.update({ @@ -36,5 +50,52 @@ export const UNSAFE_updateEnvelopeItems = async ({ ), ); + // Write audit logs for DOCUMENT type envelopes when changes are detected. + if (envelopeType === 'DOCUMENT') { + const auditLogs = data.flatMap((item) => { + const existing = existingEnvelopeItems.find((e) => e.id === item.envelopeItemId); + + if (!existing) { + return []; + } + + const changes: { field: string; from: string; to: string }[] = []; + + if (item.title !== undefined && item.title !== existing.title) { + changes.push({ + field: 'title', + from: existing.title, + to: item.title, + }); + } + + if (changes.length === 0) { + return []; + } + + return [ + createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_UPDATED, + envelopeId, + data: { + envelopeItemId: item.envelopeItemId, + changes, + }, + user: { + name: user.name, + email: user.email, + }, + requestMetadata: apiRequestMetadata.requestMetadata, + }), + ]; + }); + + if (auditLogs.length > 0) { + await prisma.documentAuditLog.createMany({ + data: auditLogs, + }); + } + } + return updatedEnvelopeItems; }; diff --git a/packages/lib/server-only/envelope/update-envelope.ts b/packages/lib/server-only/envelope/update-envelope.ts index c1ce03cac..0abbd19c9 100644 --- a/packages/lib/server-only/envelope/update-envelope.ts +++ b/packages/lib/server-only/envelope/update-envelope.ts @@ -207,9 +207,13 @@ export const updateEnvelope = async ({ const auditLogs: CreateDocumentAuditLogDataResponse[] = []; - if (!isTitleSame && envelope.status !== DocumentStatus.DRAFT) { + if ( + !isTitleSame && + envelope.status !== DocumentStatus.DRAFT && + envelope.status !== DocumentStatus.PENDING + ) { throw new AppError(AppErrorCode.INVALID_BODY, { - message: 'You cannot update the title if the envelope has been sent', + message: 'Envelope title can only be updated while in draft or pending status', }); } diff --git a/packages/lib/types/document-audit-logs.ts b/packages/lib/types/document-audit-logs.ts index df6de4765..5c8126cc9 100644 --- a/packages/lib/types/document-audit-logs.ts +++ b/packages/lib/types/document-audit-logs.ts @@ -23,6 +23,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([ 'ENVELOPE_ITEM_CREATED', 'ENVELOPE_ITEM_DELETED', + 'ENVELOPE_ITEM_UPDATED', 'ENVELOPE_ITEM_PDF_REPLACED', // Document events. @@ -210,6 +211,23 @@ export const ZDocumentAuditLogEventEnvelopeItemDeletedSchema = z.object({ }), }); +/** + * Event: Envelope item updated. + */ +export const ZDocumentAuditLogEventEnvelopeItemUpdatedSchema = z.object({ + type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_UPDATED), + data: z.object({ + envelopeItemId: z.string(), + changes: z.array( + z.object({ + field: z.string(), + from: z.string(), + to: z.string(), + }), + ), + }), +}); + /** * Event: Envelope item PDF replaced. */ @@ -734,6 +752,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and( z.union([ ZDocumentAuditLogEventEnvelopeItemCreatedSchema, ZDocumentAuditLogEventEnvelopeItemDeletedSchema, + ZDocumentAuditLogEventEnvelopeItemUpdatedSchema, ZDocumentAuditLogEventEnvelopeItemPdfReplacedSchema, ZDocumentAuditLogEventEmailSentSchema, ZDocumentAuditLogEventDocumentCompletedSchema, diff --git a/packages/lib/utils/document-audit-logs.ts b/packages/lib/utils/document-audit-logs.ts index c89ca05f6..850068cf4 100644 --- a/packages/lib/utils/document-audit-logs.ts +++ b/packages/lib/utils/document-audit-logs.ts @@ -571,6 +571,14 @@ export const formatDocumentAuditLogAction = ( you: msg`You deleted an envelope item with title ${data.envelopeItemTitle}`, user: msg`${user} deleted an envelope item with title ${data.envelopeItemTitle}`, })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_UPDATED }, () => ({ + anonymous: msg({ + message: `Envelope item updated`, + context: `Audit log format`, + }), + you: msg`You updated an envelope item`, + user: msg`${user} updated an envelope item`, + })) .with({ type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_PDF_REPLACED }, ({ data }) => ({ anonymous: msg({ message: `Envelope item PDF replaced`, diff --git a/packages/lib/utils/envelope.ts b/packages/lib/utils/envelope.ts index a0628106e..090e77f6f 100644 --- a/packages/lib/utils/envelope.ts +++ b/packages/lib/utils/envelope.ts @@ -244,28 +244,57 @@ export const mapSecondaryIdToTemplateId = (secondaryId: string) => { return parseInt(parsed.data.split('_')[1]); }; -export const canEnvelopeItemsBeModified = ( +export type EnvelopeItemPermissions = { + canTitleBeChanged: boolean; + canFileBeChanged: boolean; + canOrderBeChanged: boolean; +}; + +export const getEnvelopeItemPermissions = ( envelope: Pick, recipients: Recipient[], -) => { - if (envelope.completedAt || envelope.deletedAt || envelope.status !== DocumentStatus.DRAFT) { - return false; - } - - if (envelope.type === EnvelopeType.TEMPLATE) { - return true; - } - +): EnvelopeItemPermissions => { + // Always reject completed/rejected/deleted envelopes. if ( - recipients.some( - (recipient) => - recipient.role !== RecipientRole.CC && - (recipient.signingStatus === SigningStatus.SIGNED || - recipient.sendStatus === SendStatus.SENT), - ) + envelope.completedAt || + envelope.deletedAt || + envelope.status === DocumentStatus.REJECTED || + envelope.status === DocumentStatus.COMPLETED ) { - return false; + return { + canTitleBeChanged: false, + canFileBeChanged: false, + canOrderBeChanged: false, + }; } - return true; + // Templates can always be modified. + if (envelope.type === EnvelopeType.TEMPLATE) { + return { + canTitleBeChanged: true, + canFileBeChanged: true, + canOrderBeChanged: true, + }; + } + + const hasActiveRecipients = recipients.some( + (recipient) => + recipient.role !== RecipientRole.CC && + (recipient.signingStatus === SigningStatus.SIGNED || + recipient.signingStatus === SigningStatus.REJECTED || + recipient.sendStatus === SendStatus.SENT), + ); + + return match(envelope.status) + .with(DocumentStatus.DRAFT, () => ({ + canTitleBeChanged: true, + canFileBeChanged: true, + canOrderBeChanged: true, + })) + .with(DocumentStatus.PENDING, () => ({ + canTitleBeChanged: true, + canFileBeChanged: false, + canOrderBeChanged: !hasActiveRecipients, // Only allow order changes if no active recipients. + })) + .exhaustive(); }; diff --git a/packages/trpc/server/embedding-router/update-embedding-envelope.ts b/packages/trpc/server/embedding-router/update-embedding-envelope.ts index c9408c729..8ec2980a6 100644 --- a/packages/trpc/server/embedding-router/update-embedding-envelope.ts +++ b/packages/trpc/server/embedding-router/update-embedding-envelope.ts @@ -16,7 +16,7 @@ import { setDocumentRecipients } from '@documenso/lib/server-only/recipient/set- import { setTemplateRecipients } from '@documenso/lib/server-only/recipient/set-template-recipients'; import { nanoid } from '@documenso/lib/universal/id'; import { PRESIGNED_ENVELOPE_ITEM_ID_PREFIX } from '@documenso/lib/utils/embed-config'; -import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope'; +import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope'; import { prisma } from '@documenso/prisma'; import { procedure } from '../trpc'; @@ -191,10 +191,36 @@ export const updateEmbeddingEnvelopeRoute = procedure // Should be safe to use stale envelope.recipients since only signed or sent // recipients affect the outcome. - if (willEnvelopeItemsBeModified && !canEnvelopeItemsBeModified(envelope, envelope.recipients)) { - throw new AppError(AppErrorCode.INVALID_REQUEST, { - message: 'Envelope item is not editable', + if (willEnvelopeItemsBeModified) { + const permissions = getEnvelopeItemPermissions(envelope, envelope.recipients); + + const hasFileChange = envelopeItemIdsToDelete.length > 0 || envelopeItemsToCreate.length > 0; + + const hasOrderChange = envelopeItemsToUpdate.some((item) => { + const existing = envelope.envelopeItems.find((e) => e.id === item.envelopeItemId); + + return !existing || item.order !== existing.order; }); + + const hasTitleChange = envelopeItemsToUpdate.some((item) => item.title !== undefined); + + if (hasFileChange && !permissions.canFileBeChanged) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Envelope item files are not editable', + }); + } + + if (hasOrderChange && !permissions.canOrderBeChanged) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Envelope item order is not editable', + }); + } + + if (hasTitleChange && !permissions.canTitleBeChanged) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Envelope item title is not editable', + }); + } } if (envelopeItemIdsToDelete.length > 0) { @@ -252,7 +278,14 @@ export const updateEmbeddingEnvelopeRoute = procedure if (envelopeItemsToUpdate.length > 0) { await UNSAFE_updateEnvelopeItems({ envelopeId: envelope.id, + envelopeType: envelope.type, + existingEnvelopeItems: envelope.envelopeItems, data: envelopeItemsToUpdate, + user: { + name: apiToken.user.name, + email: apiToken.user.email, + }, + apiRequestMetadata: ctx.metadata, }); } diff --git a/packages/trpc/server/envelope-router/create-envelope-items.ts b/packages/trpc/server/envelope-router/create-envelope-items.ts index bee9d555d..cf6f2cb3d 100644 --- a/packages/trpc/server/envelope-router/create-envelope-items.ts +++ b/packages/trpc/server/envelope-router/create-envelope-items.ts @@ -1,7 +1,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { UNSAFE_createEnvelopeItems } from '@documenso/lib/server-only/envelope-item/create-envelope-items'; import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; -import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope'; +import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope'; import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; @@ -63,7 +63,9 @@ export const createEnvelopeItemsRoute = authenticatedProcedure }); } - if (!canEnvelopeItemsBeModified(envelope, envelope.recipients)) { + const { canFileBeChanged } = getEnvelopeItemPermissions(envelope, envelope.recipients); + + if (!canFileBeChanged) { throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Envelope item is not editable', }); diff --git a/packages/trpc/server/envelope-router/delete-envelope-item.ts b/packages/trpc/server/envelope-router/delete-envelope-item.ts index 099c1f477..8d824fb6a 100644 --- a/packages/trpc/server/envelope-router/delete-envelope-item.ts +++ b/packages/trpc/server/envelope-router/delete-envelope-item.ts @@ -1,7 +1,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { UNSAFE_deleteEnvelopeItem } from '@documenso/lib/server-only/envelope-item/delete-envelope-item'; import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; -import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope'; +import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope'; import { prisma } from '@documenso/prisma'; import { ZGenericSuccessResponse } from '../schema'; @@ -50,7 +50,9 @@ export const deleteEnvelopeItemRoute = authenticatedProcedure }); } - if (!canEnvelopeItemsBeModified(envelope, envelope.recipients)) { + const { canFileBeChanged } = getEnvelopeItemPermissions(envelope, envelope.recipients); + + if (!canFileBeChanged) { throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Envelope item is not editable', }); diff --git a/packages/trpc/server/envelope-router/replace-envelope-item-pdf.ts b/packages/trpc/server/envelope-router/replace-envelope-item-pdf.ts index 68bb8878c..c570170fe 100644 --- a/packages/trpc/server/envelope-router/replace-envelope-item-pdf.ts +++ b/packages/trpc/server/envelope-router/replace-envelope-item-pdf.ts @@ -1,7 +1,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { UNSAFE_replaceEnvelopeItemPdf } from '@documenso/lib/server-only/envelope-item/replace-envelope-item-pdf'; import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; -import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope'; +import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope'; import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; @@ -65,7 +65,9 @@ export const replaceEnvelopeItemPdfRoute = authenticatedProcedure }); } - if (!canEnvelopeItemsBeModified(envelope, envelope.recipients)) { + const { canFileBeChanged } = getEnvelopeItemPermissions(envelope, envelope.recipients); + + if (!canFileBeChanged) { throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Envelope item is not editable', }); diff --git a/packages/trpc/server/envelope-router/update-envelope-items.ts b/packages/trpc/server/envelope-router/update-envelope-items.ts index 5e331b1b5..2eaf77d97 100644 --- a/packages/trpc/server/envelope-router/update-envelope-items.ts +++ b/packages/trpc/server/envelope-router/update-envelope-items.ts @@ -1,7 +1,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { UNSAFE_updateEnvelopeItems } from '@documenso/lib/server-only/envelope-item/update-envelope-items'; import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; -import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope'; +import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope'; import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; @@ -55,12 +55,40 @@ export const updateEnvelopeItemsRoute = authenticatedProcedure }); } - // Note: This logic is duplicated in many places. If we plan to allow changing title/order - // even after the envelope has been sent, make sure to update it everywhere including - // embedding routes. - if (!canEnvelopeItemsBeModified(envelope, envelope.recipients)) { + const permissions = getEnvelopeItemPermissions(envelope, envelope.recipients); + + const hasOrderChange = data.some((item) => { + if (item.order === undefined) { + return false; + } + + const existingItem = envelope.envelopeItems.find((e) => e.id === item.envelopeItemId); + + return !existingItem || existingItem.order !== item.order; + }); + + const hasTitleChange = data.some((item) => item.title !== undefined); + + if (!hasTitleChange && !hasOrderChange) { + return { + data: envelope.envelopeItems.map((item) => ({ + id: item.id, + order: item.order, + title: item.title, + envelopeId: item.envelopeId, + })), + }; + } + + if (hasTitleChange && !permissions.canTitleBeChanged) { throw new AppError(AppErrorCode.INVALID_REQUEST, { - message: 'Envelope item is not editable', + message: 'Envelope item title is not editable', + }); + } + + if (hasOrderChange && !permissions.canOrderBeChanged) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Envelope item order is not editable', }); } @@ -77,7 +105,14 @@ export const updateEnvelopeItemsRoute = authenticatedProcedure const updatedEnvelopeItems = await UNSAFE_updateEnvelopeItems({ envelopeId, + envelopeType: envelope.type, + existingEnvelopeItems: envelope.envelopeItems, data, + user: { + name: user.name, + email: user.email, + }, + apiRequestMetadata: ctx.metadata, }); return { diff --git a/packages/trpc/server/trpc.ts b/packages/trpc/server/trpc.ts index 9b53d1809..7d427c3ed 100644 --- a/packages/trpc/server/trpc.ts +++ b/packages/trpc/server/trpc.ts @@ -73,7 +73,7 @@ const t = initTRPC /** * Middlewares */ -export const authenticatedMiddleware = t.middleware(async ({ ctx, next, path }) => { +export const authenticatedMiddleware = t.middleware(async ({ ctx, next, path, meta }) => { const infoToLog: TrpcApiLog = { path, auth: ctx.metadata.auth, @@ -84,8 +84,10 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next, path }) const authorizationHeader = ctx.req.headers.get('authorization'); + const isApiV2 = Boolean(meta?.openapi?.path); + // Taken from `authenticatedMiddleware` in `@documenso/api/v1/middleware/authenticated.ts`. - if (authorizationHeader) { + if (authorizationHeader && isApiV2) { // Support for both "Authorization: Bearer api_xxx" and "Authorization: api_xxx" const [token] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0); @@ -164,7 +166,7 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next, path }) }); }); -export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next, path }) => { +export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next, path, meta }) => { // Recreate the logger with a sub request ID to differentiate between batched requests. const trpcSessionLogger = ctx.logger.child({ nonBatchedRequestId: alphaid(), @@ -180,8 +182,10 @@ export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next, pat const authorizationHeader = ctx.req.headers.get('authorization'); + const isApiV2 = Boolean(meta?.openapi?.path); + // Taken from `authenticatedMiddleware` in `@documenso/api/v1/middleware/authenticated.ts`. - if (authorizationHeader) { + if (authorizationHeader && isApiV2) { // Support for both "Authorization: Bearer api_xxx" and "Authorization: api_xxx" const [token] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);