feat: allow editing pending envelope titles (#2604)

This commit is contained in:
David Nguyen 2026-03-19 14:03:30 +11:00 committed by GitHub
parent 0b605d61c6
commit 48fb066b9a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 860 additions and 60 deletions

View file

@ -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) => (
<div className="relative flex h-5 w-5 flex-shrink-0 items-center justify-center">
<div

View file

@ -1,3 +1,5 @@
import { useMemo } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus, EnvelopeType, TemplateType } from '@prisma/client';
import {
@ -13,7 +15,10 @@ import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import {
getEnvelopeItemPermissions,
mapSecondaryIdToTemplateId,
} from '@documenso/lib/utils/envelope';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
@ -50,6 +55,11 @@ export default function EnvelopeEditorHeader() {
actions: { allowAttachments, allowDistributing },
} = editorConfig;
const envelopeItemPermissions = useMemo(
() => getEnvelopeItemPermissions(envelope, envelope.recipients),
[envelope, envelope.recipients],
);
const handleCreateEmbeddedEnvelope = async () => {
const latestEnvelope = await flushAutosave();
@ -81,7 +91,8 @@ export default function EnvelopeEditorHeader() {
<div className="flex items-center space-x-2">
<EnvelopeItemTitleInput
disabled={envelope.status !== DocumentStatus.DRAFT || !allowConfigureEnvelopeTitle}
dataTestId="envelope-title-input"
disabled={!envelopeItemPermissions.canTitleBeChanged || !allowConfigureEnvelopeTitle}
value={envelope.title}
onChange={(title) => {
updateEnvelope({

View file

@ -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 ? (
<EnvelopeItemTitleInput
disabled={
envelope.status !== DocumentStatus.DRAFT ||
!envelopeItemPermissions.canTitleBeChanged ||
!uploadConfig?.allowConfigureTitle ||
localFile.isReplacing
}
@ -579,7 +578,7 @@ export const EnvelopeEditorUploadPage = () => {
)}
{localFile.envelopeItemId &&
canItemsBeModified &&
envelopeItemPermissions.canFileBeChanged &&
uploadConfig?.allowReplace && (
<Button
variant="ghost"
@ -613,7 +612,7 @@ export const EnvelopeEditorUploadPage = () => {
</Button>
) : (
<EnvelopeItemDeleteDialog
canItemBeDeleted={canItemsBeModified}
canItemBeDeleted={envelopeItemPermissions.canFileBeChanged}
envelopeId={envelope.id}
envelopeItemId={localFile.envelopeItemId}
envelopeItemTitle={localFile.title}

View file

@ -0,0 +1,519 @@
import { type APIRequestContext, expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
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, EnvelopeType, FieldType, RecipientRole } from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TDistributeEnvelopeRequest } from '@documenso/trpc/server/envelope-router/distribute-envelope.types';
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
import type { TUpdateEnvelopeItemsRequest } from '@documenso/trpc/server/envelope-router/update-envelope-items.types';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({
mode: 'parallel',
});
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
const createEnvelope = async (request: APIRequestContext, authToken: string) => {
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<string, unknown>;
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();
});
});

View file

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

View file

@ -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<EnvelopeItem, 'id' | 'title' | 'order'>[];
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;
};

View file

@ -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',
});
}

View file

@ -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,

View file

@ -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`,

View file

@ -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<Envelope, 'completedAt' | 'deletedAt' | 'type' | 'status'>,
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();
};

View file

@ -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,
});
}

View file

@ -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',
});

View file

@ -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',
});

View file

@ -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',
});

View file

@ -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 {

View file

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