mirror of
https://github.com/documenso/documenso
synced 2026-04-21 13:27:18 +00:00
feat: allow editing pending envelope titles (#2604)
This commit is contained in:
parent
0b605d61c6
commit
48fb066b9a
16 changed files with 860 additions and 60 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
519
packages/app-tests/e2e/api/v2/update-envelope-items.spec.ts
Normal file
519
packages/app-tests/e2e/api/v2/update-envelope-items.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue