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);