From 0b605d61c62a5d418e43a03c514026923eb4f9dc Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 18 Mar 2026 22:53:28 +1100 Subject: [PATCH] feat: add envelope pdf replacement (#2602) --- .../developers/embedding/authoring/v2.mdx | 1 + .../dialogs/envelope-item-edit-dialog.tsx | 368 ++++++++++++ .../authoring/configure-document-upload.tsx | 17 +- .../document-upload-button-legacy.tsx | 7 +- .../envelope-editor-fields-page.tsx | 44 +- .../envelope-editor-upload-page.tsx | 163 +++++- .../envelope-file-selector.tsx | 19 +- .../envelope/envelope-drop-zone-wrapper.tsx | 45 +- .../envelope/envelope-upload-button.tsx | 6 +- apps/remix/app/routes/embed+/playground.tsx | 1 + .../v2+/authoring+/envelope.edit.$id.tsx | 14 +- apps/remix/vite.config.ts | 4 + .../envelope-item-edit-dialog.spec.ts | 321 +++++++++++ .../envelope-replace-pdf.spec.ts | 532 ++++++++++++++++++ .../app-tests/e2e/fixtures/envelope-editor.ts | 3 + .../internal/seal-document.handler.ts | 2 +- .../envelope-item/create-envelope-items.ts | 4 +- .../replace-envelope-item-pdf.ts | 233 ++++++++ .../server-only/envelope/create-envelope.ts | 2 +- .../field/create-envelope-fields.ts | 2 +- .../create-document-from-direct-template.ts | 10 +- packages/lib/types/document-audit-logs.ts | 13 + packages/lib/types/envelope-editor.ts | 3 + .../lib/universal/upload/put-file.server.ts | 7 +- packages/lib/utils/document-audit-logs.ts | 8 + packages/lib/utils/embed-config.ts | 3 + .../document-router/create-document.types.ts | 4 +- .../update-embedding-envelope.ts | 60 +- .../update-embedding-envelope.types.ts | 14 +- .../create-envelope-items.types.ts | 4 +- .../server/envelope-router/create-envelope.ts | 4 +- .../envelope-router/create-envelope.types.ts | 4 +- .../replace-envelope-item-pdf.ts | 103 ++++ .../replace-envelope-item-pdf.types.ts | 42 ++ .../trpc/server/envelope-router/router.ts | 2 + .../envelope-router/use-envelope.types.ts | 4 +- .../trpc/server/template-router/schema.ts | 4 +- packages/trpc/utils/zod-form-data.ts | 17 + packages/ui/lib/handle-dropzone-rejection.tsx | 19 + 39 files changed, 2003 insertions(+), 110 deletions(-) create mode 100644 apps/remix/app/components/dialogs/envelope-item-edit-dialog.tsx create mode 100644 packages/app-tests/e2e/envelope-editor-v2/envelope-item-edit-dialog.spec.ts create mode 100644 packages/app-tests/e2e/envelope-editor-v2/envelope-replace-pdf.spec.ts create mode 100644 packages/lib/server-only/envelope-item/replace-envelope-item-pdf.ts create mode 100644 packages/trpc/server/envelope-router/replace-envelope-item-pdf.ts create mode 100644 packages/trpc/server/envelope-router/replace-envelope-item-pdf.types.ts create mode 100644 packages/ui/lib/handle-dropzone-rejection.tsx diff --git a/apps/docs/content/docs/developers/embedding/authoring/v2.mdx b/apps/docs/content/docs/developers/embedding/authoring/v2.mdx index ef4091164..a21a4069c 100644 --- a/apps/docs/content/docs/developers/embedding/authoring/v2.mdx +++ b/apps/docs/content/docs/developers/embedding/authoring/v2.mdx @@ -203,6 +203,7 @@ Controls how envelope items (individual files within the envelope) can be manage | `allowConfigureOrder` | `boolean` | `true` | Allow reordering items | | `allowUpload` | `boolean` | `true` | Allow uploading new items | | `allowDelete` | `boolean` | `true` | Allow deleting items | +| `allowReplace` | `boolean` | `true` | Allow replacing an item's PDF | ### Recipients diff --git a/apps/remix/app/components/dialogs/envelope-item-edit-dialog.tsx b/apps/remix/app/components/dialogs/envelope-item-edit-dialog.tsx new file mode 100644 index 000000000..7115bb4bb --- /dev/null +++ b/apps/remix/app/components/dialogs/envelope-item-edit-dialog.tsx @@ -0,0 +1,368 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Plural, Trans, useLingui } from '@lingui/react/macro'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { AlertTriangleIcon, FileIcon, UploadIcon, XIcon } from 'lucide-react'; +import { type FileRejection, useDropzone } from 'react-dropzone'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; +import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; +import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions'; +import { trpc } from '@documenso/trpc/react'; +import { ZDocumentTitleSchema } from '@documenso/trpc/server/document-router/schema'; +import type { TReplaceEnvelopeItemPdfPayload } from '@documenso/trpc/server/envelope-router/replace-envelope-item-pdf.types'; +import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection'; +import { cn } from '@documenso/ui/lib/utils'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +const ZEditEnvelopeItemFormSchema = z.object({ + title: ZDocumentTitleSchema, +}); + +type TEditEnvelopeItemFormSchema = z.infer; + +/** + * Note: This should only be visible if the envelope item is editable. + */ +export type EnvelopeItemEditDialogProps = { + envelopeItem: { id: string; title: string }; + allowConfigureTitle: boolean; + trigger: React.ReactNode; +} & Omit; + +export const EnvelopeItemEditDialog = ({ + envelopeItem, + allowConfigureTitle, + trigger, + ...props +}: EnvelopeItemEditDialogProps) => { + const { t, i18n } = useLingui(); + const { toast } = useToast(); + + const { envelope, editorFields, setLocalEnvelope, isEmbedded } = useCurrentEnvelopeEditor(); + + const [isOpen, setIsOpen] = useState(false); + const [replacementFile, setReplacementFile] = useState<{ file: File; pageCount: number } | null>( + null, + ); + const [isDropping, setIsDropping] = useState(false); + + const form = useForm({ + resolver: zodResolver(ZEditEnvelopeItemFormSchema), + defaultValues: { + title: envelopeItem.title, + }, + }); + + const { mutateAsync: replaceEnvelopeItemPdf } = trpc.envelope.item.replacePdf.useMutation({ + onSuccess: ({ data, fields }) => { + setLocalEnvelope({ + envelopeItems: envelope.envelopeItems.map((item) => + item.id === data.id + ? { ...item, documentDataId: data.documentDataId, title: data.title } + : item, + ), + }); + + if (fields) { + setLocalEnvelope({ fields }); + editorFields.resetForm(fields); + } + }, + }); + + const fieldsOnExcessPages = + replacementFile !== null + ? envelope.fields.filter( + (field) => + field.envelopeItemId === envelopeItem.id && field.page > replacementFile.pageCount, + ) + : []; + + const onFileDropRejected = (fileRejections: FileRejection[]) => { + toast({ + title: t`Upload failed`, + description: i18n._(buildDropzoneRejectionDescription(fileRejections)), + duration: 5000, + variant: 'destructive', + }); + }; + + const onFileDrop = async (files: File[]) => { + const file = files[0]; + + if (!file || isDropping) { + return; + } + + setIsDropping(true); + + try { + const arrayBuffer = await file.arrayBuffer(); + const fileData = new Uint8Array(arrayBuffer.slice(0)); + const { PDF } = await import('@libpdf/core'); + const pdfDoc = await PDF.load(fileData); + + setReplacementFile({ + file, + pageCount: pdfDoc.getPageCount(), + }); + } catch (err) { + console.error(err); + + toast({ + title: t`Failed to read file`, + description: t`The file is not a valid PDF.`, + variant: 'destructive', + }); + } + + setIsDropping(false); + }; + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + accept: { 'application/pdf': ['.pdf'] }, + maxFiles: 1, + maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT), + disabled: form.formState.isSubmitting, + onDrop: (files) => void onFileDrop(files), + onDropRejected: onFileDropRejected, + }); + + const onSubmit = async (data: TEditEnvelopeItemFormSchema) => { + if (isDropping || !replacementFile) { + return; + } + + try { + const { file, pageCount } = replacementFile; + + if (isEmbedded) { + const arrayBuffer = await file.arrayBuffer(); + const fileData = new Uint8Array(arrayBuffer.slice(0)); + + const remainingFields = envelope.fields.filter( + (field) => field.envelopeItemId !== envelopeItem.id || field.page <= pageCount, + ); + + setLocalEnvelope({ + envelopeItems: envelope.envelopeItems.map((item) => + item.id === envelopeItem.id ? { ...item, title: data.title, data: fileData } : item, + ), + fields: remainingFields, + }); + + editorFields.resetForm(remainingFields); + } else { + const payload = { + envelopeId: envelope.id, + envelopeItemId: envelopeItem.id, + title: data.title, + } satisfies TReplaceEnvelopeItemPdfPayload; + + const formData = new FormData(); + formData.append('payload', JSON.stringify(payload)); + formData.append('file', file); + + await replaceEnvelopeItemPdf(formData); + } + + setIsOpen(false); + } catch { + toast({ + title: t`Failed to update item`, + description: t`Something went wrong while updating the envelope item.`, + variant: 'destructive', + }); + } + }; + + useEffect(() => { + if (!isOpen) { + form.reset({ title: envelopeItem.title }); + setReplacementFile(null); + setIsDropping(false); + } + }, [isOpen, form, envelopeItem.title]); + + const formatFileSize = (bytes: number) => { + if (bytes < 1024) { + return `${bytes} B`; + } + + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + }; + + return ( + !form.formState.isSubmitting && setIsOpen(value)} + > + e.stopPropagation()} asChild> + {trigger} + + + + + + Edit Item + + + Update the title or replace the PDF file. + + + +
+ +
+ ( + + + Document Title + + + + + + + )} + /> + +
+ + Replace PDF + + + {replacementFile ? ( +
+
+
+ +
+

+ {replacementFile.file.name} +

+

+ {formatFileSize(replacementFile.file.size)} + {isDropping ? ' · …' : ' · '} + {!isDropping && replacementFile.pageCount !== null && ( + + )} +

+
+
+ +
+ + {fieldsOnExcessPages.length > 0 && ( + + + + + + + )} +
+ ) : ( +
+ +
+ + + Drop PDF here or click to select + +
+
+ )} +
+ + + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/embed/authoring/configure-document-upload.tsx b/apps/remix/app/components/embed/authoring/configure-document-upload.tsx index a86ffbcaf..87b932820 100644 --- a/apps/remix/app/components/embed/authoring/configure-document-upload.tsx +++ b/apps/remix/app/components/embed/authoring/configure-document-upload.tsx @@ -4,10 +4,11 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { Cloud, FileText, Loader, X } from 'lucide-react'; -import { useDropzone } from 'react-dropzone'; +import { type FileRejection, useDropzone } from 'react-dropzone'; import { useFormContext } from 'react-hook-form'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; +import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -82,10 +83,10 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum } }; - const onDropRejected = () => { + const onDropRejected = (fileRejections: FileRejection[]) => { toast({ title: _(msg`Your document failed to upload.`), - description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`), + description: _(buildDropzoneRejectionDescription(fileRejections)), duration: 5000, variant: 'destructive', }); @@ -144,7 +145,7 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
{isLoading && ( -
- +
+
)}
) : (
-
+
{documentData.name}
-
+
{formatFileSize(documentData.size)}
diff --git a/apps/remix/app/components/general/document/document-upload-button-legacy.tsx b/apps/remix/app/components/general/document/document-upload-button-legacy.tsx index 9c031e821..16388e6cf 100644 --- a/apps/remix/app/components/general/document/document-upload-button-legacy.tsx +++ b/apps/remix/app/components/general/document/document-upload-button-legacy.tsx @@ -4,6 +4,7 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { EnvelopeType } from '@prisma/client'; +import type { FileRejection } from 'react-dropzone'; import { useNavigate, useParams } from 'react-router'; import { match } from 'ts-pattern'; @@ -11,13 +12,13 @@ import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useSession } from '@documenso/lib/client-only/providers/session'; -import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; import { trpc } from '@documenso/trpc/react'; import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types'; import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema'; +import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection'; import { cn } from '@documenso/ui/lib/utils'; import { DocumentUploadButton as DocumentUploadButtonPrimitive } from '@documenso/ui/primitives/document-upload-button'; import { @@ -162,10 +163,10 @@ export const DocumentUploadButtonLegacy = ({ } }; - const onFileDropRejected = () => { + const onFileDropRejected = (fileRejections: FileRejection[]) => { toast({ title: _(msg`Your document failed to upload.`), - description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`), + description: _(buildDropzoneRejectionDescription(fileRejections)), duration: 5000, variant: 'destructive', }); diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx index 19a41a2da..7403ae4b3 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx @@ -5,7 +5,7 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client'; -import { FileTextIcon, SparklesIcon } from 'lucide-react'; +import { FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react'; import { useRevalidator, useSearchParams } from 'react-router'; import { isDeepEqual } from 'remeda'; import { match } from 'ts-pattern'; @@ -28,14 +28,17 @@ import { type TSignatureFieldMeta, type TTextFieldMeta, } from '@documenso/lib/types/field-meta'; +import { canEnvelopeItemsBeModified } 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'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { Separator } from '@documenso/ui/primitives/separator'; import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog'; import { AiFieldDetectionDialog } from '~/components/dialogs/ai-field-detection-dialog'; +import { EnvelopeItemEditDialog } from '~/components/dialogs/envelope-item-edit-dialog'; import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form'; import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form'; import { EditorFieldDropdownForm } from '~/components/forms/editor/editor-field-dropdown-form'; @@ -85,6 +88,11 @@ export const EnvelopeEditorFieldsPage = () => { const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false); const { revalidate } = useRevalidator(); + const canItemsBeModified = useMemo( + () => canEnvelopeItemsBeModified(envelope, envelope.recipients), + [envelope, envelope.recipients], + ); + const selectedField = useMemo( () => structuredClone(editorFields.selectedField), [editorFields.selectedField], @@ -157,7 +165,39 @@ export const EnvelopeEditorFieldsPage = () => { ref={scrollableContainerRef} > {/* Horizontal envelope item selector */} - + ( +
+
+ e.stopPropagation()} + data-testid={`envelope-item-edit-button-${item.id}`} + > + + + } + /> +
+ ) + : undefined + } + /> {/* Document View */}
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 c8a8c279d..255305d86 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 @@ -5,9 +5,8 @@ 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, Loader2 } from 'lucide-react'; -import { X } from 'lucide-react'; -import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone'; +import { FileWarningIcon, GripVerticalIcon, Loader2Icon, PencilIcon, XIcon } from 'lucide-react'; +import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from 'react-dropzone'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useEnvelopeAutosave } from '@documenso/lib/client-only/hooks/use-envelope-autosave'; @@ -16,10 +15,13 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; 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 { 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'; +import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection'; import { Button } from '@documenso/ui/primitives/button'; import { Card, @@ -41,13 +43,14 @@ type LocalFile = { title: string; envelopeItemId: string | null; isUploading: boolean; + isReplacing: boolean; isError: boolean; }; export const EnvelopeEditorUploadPage = () => { const organisation = useCurrentOrganisation(); - const { t } = useLingui(); + const { t, i18n } = useLingui(); const { maximumEnvelopeItemCount, remaining } = useLimits(); const { toast } = useToast(); @@ -72,10 +75,36 @@ export const EnvelopeEditorUploadPage = () => { title: item.title, envelopeItemId: item.id, isUploading: false, + isReplacing: false, isError: false, })), ); + const replacingItemIdRef = useRef(null); + + const { open: openReplaceFilePicker, getInputProps: getReplaceInputProps } = useDropzone({ + accept: { 'application/pdf': ['.pdf'] }, + maxFiles: 1, + maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT), + multiple: false, + noClick: true, + noKeyboard: true, + noDrag: true, + onDrop: (acceptedFiles) => { + const file = acceptedFiles[0]; + const replacingItemId = replacingItemIdRef.current; + + if (file && replacingItemId) { + void onReplacePdf(replacingItemId, file); + replacingItemIdRef.current = null; + } + }, + onDropRejected: (fileRejections) => void onFileDropRejected(fileRejections), + onFileDialogCancel: () => { + replacingItemIdRef.current = null; + }, + }); + const { mutateAsync: createEnvelopeItems, isPending: isCreatingEnvelopeItems } = trpc.envelope.item.createMany.useMutation({ onSuccess: ({ data }) => { @@ -108,6 +137,24 @@ export const EnvelopeEditorUploadPage = () => { }, }); + const { mutateAsync: replaceEnvelopeItemPdf } = trpc.envelope.item.replacePdf.useMutation({ + onSuccess: ({ data, fields }) => { + // Update the envelope item with the new documentDataId. + setLocalEnvelope({ + envelopeItems: envelope.envelopeItems.map((item) => + item.id === data.id ? { ...item, documentDataId: data.documentDataId } : item, + ), + }); + + // When fields were created or deleted during the replacement, + // the server returns the full updated field list. + if (fields) { + setLocalEnvelope({ fields }); + editorFields.resetForm(fields); + } + }, + }); + const canItemsBeModified = useMemo( () => canEnvelopeItemsBeModified(envelope, envelope.recipients), [envelope, envelope.recipients], @@ -125,6 +172,7 @@ export const EnvelopeEditorUploadPage = () => { title: file.name, file, isUploading: isEmbedded ? false : true, + isReplacing: false, // Clone the buffer so it can be read multiple times (File.arrayBuffer() consumes the stream once) data: isEmbedded ? new Uint8Array((await file.arrayBuffer()).slice(0)) : null, isError: false, @@ -197,12 +245,77 @@ export const EnvelopeEditorUploadPage = () => { envelopeItemId: item.id, title: item.title, isUploading: false, + isReplacing: false, isError: false, })), ); }); }; + const onReplacePdf = async (envelopeItemId: string, file: File) => { + setLocalFiles((prev) => + prev.map((f) => (f.envelopeItemId === envelopeItemId ? { ...f, isReplacing: true } : f)), + ); + + try { + if (isEmbedded) { + // For embedded mode, store the file data locally on the envelope item. + // The actual replacement will happen when the embed flow submits. + const arrayBuffer = await file.arrayBuffer(); + const data = new Uint8Array(arrayBuffer.slice(0)); + + // Count pages in the new PDF to remove out-of-bounds fields. + const { PDF } = await import('@libpdf/core'); + const pdfDoc = await PDF.load(data); + const newPageCount = pdfDoc.getPageCount(); + + // Remove fields that are on pages beyond the new PDF's page count. + const remainingFields = envelope.fields.filter( + (field) => field.envelopeItemId !== envelopeItemId || field.page <= newPageCount, + ); + + setLocalEnvelope({ + envelopeItems: envelope.envelopeItems.map((item) => + item.id === envelopeItemId ? { ...item, data } : item, + ), + fields: remainingFields, + }); + + editorFields.resetForm(remainingFields); + + return; + } + + // Normal mode: upload immediately via tRPC. + const payload = { + envelopeId: envelope.id, + envelopeItemId, + } satisfies TReplaceEnvelopeItemPdfPayload; + + const formData = new FormData(); + formData.append('payload', JSON.stringify(payload)); + formData.append('file', file); + + const replacePromise = replaceEnvelopeItemPdf(formData); + registerPendingMutation(replacePromise); + + await replacePromise; + } catch (error) { + console.error(error); + + toast({ + title: t`Replace failed`, + description: t`Something went wrong while replacing the PDF`, + duration: 5000, + variant: 'destructive', + }); + } finally { + setLocalFiles((prev) => + prev.map((f) => (f.envelopeItemId === envelopeItemId ? { ...f, isReplacing: false } : f)), + ); + } + }; + /** * Hide the envelope item from the list on deletion. */ @@ -346,7 +459,7 @@ export const EnvelopeEditorUploadPage = () => { toast({ title: t`Upload failed`, - description: t`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`, + description: i18n._(buildDropzoneRejectionDescription(fileRejections)), duration: 5000, variant: 'destructive', }); @@ -354,6 +467,7 @@ export const EnvelopeEditorUploadPage = () => { return (
+ @@ -395,6 +509,7 @@ export const EnvelopeEditorUploadPage = () => { key={localFile.id} isDragDisabled={ isCreatingEnvelopeItems || + localFile.isReplacing || !canItemsBeModified || !uploadConfig?.allowConfigureOrder } @@ -427,7 +542,8 @@ export const EnvelopeEditorUploadPage = () => { { Uploading ) : localFile.isError ? ( Something went wrong while uploading this file - ) : //
2.4 MB • 3 pages
- null} + ) : null}
{localFile.isUploading && (
- +
)} @@ -463,8 +578,28 @@ export const EnvelopeEditorUploadPage = () => {
)} - {!localFile.isUploading && - localFile.envelopeItemId && + {localFile.envelopeItemId && + canItemsBeModified && + uploadConfig?.allowReplace && ( + + )} + + {localFile.envelopeItemId && uploadConfig?.allowDelete && (isEmbedded ? ( ) : ( { variant="ghost" size="sm" data-testid={`envelope-item-remove-button-${localFile.id}`} + disabled={localFile.isReplacing || localFile.isUploading} > - + } /> diff --git a/apps/remix/app/components/general/envelope-editor/envelope-file-selector.tsx b/apps/remix/app/components/general/envelope-editor/envelope-file-selector.tsx index ed23229fe..b6ee1da0e 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-file-selector.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-file-selector.tsx @@ -9,6 +9,7 @@ type EnvelopeItemSelectorProps = { secondaryText: React.ReactNode; isSelected: boolean; buttonProps: React.ButtonHTMLAttributes; + actionSlot?: React.ReactNode; }; export const EnvelopeItemSelector = ({ @@ -17,11 +18,12 @@ export const EnvelopeItemSelector = ({ secondaryText, isSelected, buttonProps, + actionSlot, }: EnvelopeItemSelectorProps) => { return (
{secondaryText}
-
+ {actionSlot ?? ( +
+ )} ); }; @@ -52,12 +56,14 @@ type EnvelopeRendererFileSelectorProps = { fields: { envelopeItemId: string }[]; className?: string; secondaryOverride?: React.ReactNode; + renderItemAction?: (item: { id: string; title: string }) => React.ReactNode; }; export const EnvelopeRendererFileSelector = ({ fields, className, secondaryOverride, + renderItemAction, }: EnvelopeRendererFileSelectorProps) => { const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender(); @@ -86,6 +92,7 @@ export const EnvelopeRendererFileSelector = ({ buttonProps={{ onClick: () => setCurrentEnvelopeItem(doc.id), }} + actionSlot={renderItemAction?.(doc)} /> ))}
diff --git a/apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx b/apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx index 80e246312..46a45fd01 100644 --- a/apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx +++ b/apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx @@ -5,12 +5,7 @@ import { useLingui } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro'; import { EnvelopeType } from '@prisma/client'; import { Loader } from 'lucide-react'; -import { - ErrorCode as DropzoneErrorCode, - ErrorCode, - type FileRejection, - useDropzone, -} from 'react-dropzone'; +import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from 'react-dropzone'; import { Link, useNavigate, useParams } from 'react-router'; import { match } from 'ts-pattern'; @@ -25,6 +20,7 @@ import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions'; import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; import { trpc } from '@documenso/trpc/react'; import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types'; +import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection'; import { cn } from '@documenso/ui/lib/utils'; import { useToast } from '@documenso/ui/primitives/use-toast'; @@ -41,7 +37,7 @@ export const EnvelopeDropZoneWrapper = ({ type, className, }: EnvelopeDropZoneWrapperProps) => { - const { t } = useLingui(); + const { t, i18n } = useLingui(); const { toast } = useToast(); const { user } = useSession(); const { folderId } = useParams(); @@ -167,42 +163,9 @@ export const EnvelopeDropZoneWrapper = ({ return; } - // Since users can only upload only one file (no multi-upload), we only handle the first file rejection - const { file, errors } = fileRejections[0]; - - if (!errors.length) { - return; - } - - const errorNodes = errors.map((error, index) => ( - - {match(error.code) - .with(ErrorCode.FileTooLarge, () => ( - File is larger than {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB - )) - .with(ErrorCode.FileInvalidType, () => Only PDF files are allowed) - .with(ErrorCode.FileTooSmall, () => File is too small) - .with(ErrorCode.TooManyFiles, () => ( - Only one file can be uploaded at a time - )) - .otherwise(() => ( - Unknown error - ))} - - )); - - const description = ( - <> - - {file.name} couldn't be uploaded: - - {errorNodes} - - ); - toast({ title: t`Upload failed`, - description, + description: i18n._(buildDropzoneRejectionDescription(fileRejections)), duration: 5000, variant: 'destructive', }); diff --git a/apps/remix/app/components/general/envelope/envelope-upload-button.tsx b/apps/remix/app/components/general/envelope/envelope-upload-button.tsx index 3b6d40a50..69aa0a5f8 100644 --- a/apps/remix/app/components/general/envelope/envelope-upload-button.tsx +++ b/apps/remix/app/components/general/envelope/envelope-upload-button.tsx @@ -11,12 +11,12 @@ import { match } from 'ts-pattern'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useSession } from '@documenso/lib/client-only/providers/session'; -import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; import { trpc } from '@documenso/trpc/react'; import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types'; +import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection'; import { cn } from '@documenso/ui/lib/utils'; import { DocumentUploadButton } from '@documenso/ui/primitives/document-upload-button'; import { @@ -39,7 +39,7 @@ export type EnvelopeUploadButtonProps = { * Upload an envelope */ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUploadButtonProps) => { - const { t } = useLingui(); + const { t, i18n } = useLingui(); const { toast } = useToast(); const { user } = useSession(); @@ -168,7 +168,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo toast({ title: t`Upload failed`, - description: t`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`, + description: i18n._(buildDropzoneRejectionDescription(fileRejections)), duration: 5000, variant: 'destructive', }); diff --git a/apps/remix/app/routes/embed+/playground.tsx b/apps/remix/app/routes/embed+/playground.tsx index 5c4ec297f..9903bb8d6 100644 --- a/apps/remix/app/routes/embed+/playground.tsx +++ b/apps/remix/app/routes/embed+/playground.tsx @@ -79,6 +79,7 @@ export default function EmbedPlaygroundPage() { allowConfigureOrder: true, allowUpload: true, allowDelete: true, + allowReplace: true, }); const [recipientsFeatures, setRecipientsFeatures] = useState({ diff --git a/apps/remix/app/routes/embed+/v2+/authoring+/envelope.edit.$id.tsx b/apps/remix/app/routes/embed+/v2+/authoring+/envelope.edit.$id.tsx index 006a02a35..f2f9f6816 100644 --- a/apps/remix/app/routes/embed+/v2+/authoring+/envelope.edit.$id.tsx +++ b/apps/remix/app/routes/embed+/v2+/authoring+/envelope.edit.$id.tsx @@ -17,7 +17,10 @@ import { ZEmbedEditEnvelopeAuthoringSchema, } from '@documenso/lib/types/envelope-editor'; import type { TEnvelopeFieldAndMeta } from '@documenso/lib/types/field-meta'; -import { buildEmbeddedEditorOptions } from '@documenso/lib/utils/embed-config'; +import { + PRESIGNED_ENVELOPE_ITEM_ID_PREFIX, + buildEmbeddedEditorOptions, +} from '@documenso/lib/utils/embed-config'; import { prisma } from '@documenso/prisma'; import { trpc } from '@documenso/trpc/react'; import type { TUpdateEmbeddingEnvelopePayload } from '@documenso/trpc/server/embedding-router/update-embedding-envelope.types'; @@ -172,7 +175,9 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => { const files: File[] = []; const envelopeItems = envelope.envelopeItems.map((item) => { - // Attach any new envelope item files to the request. + const isNewItem = item.id.startsWith(PRESIGNED_ENVELOPE_ITEM_ID_PREFIX); + + // Attach any new or replacement envelope item files to the request. if (item.data) { files.push( new File( @@ -189,7 +194,10 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => { id: item.id, title: item.title, order: item.order, - index: item.data ? files.length - 1 : undefined, + // For new items, use `index` to reference the file. + index: isNewItem && item.data ? files.length - 1 : undefined, + // For existing items being replaced, use `replaceFileIndex`. + replaceFileIndex: !isNewItem && item.data ? files.length - 1 : undefined, }; }); diff --git a/apps/remix/vite.config.ts b/apps/remix/vite.config.ts index a094c462d..0dbc5a0c4 100644 --- a/apps/remix/vite.config.ts +++ b/apps/remix/vite.config.ts @@ -59,6 +59,8 @@ export default defineConfig({ 'playwright-core', '@playwright/browser-chromium', 'pdfjs-dist', + '@google-cloud/kms', + '@google-cloud/secret-manager', ], }, optimizeDeps: { @@ -101,6 +103,8 @@ export default defineConfig({ '@napi-rs/canvas', '@node-rs/bcrypt', '@aws-sdk/cloudfront-signer', + '@google-cloud/kms', + '@google-cloud/secret-manager', 'nodemailer', /playwright/, '@playwright/browser-chromium', diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-item-edit-dialog.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-item-edit-dialog.spec.ts new file mode 100644 index 000000000..f3015cc9e --- /dev/null +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-item-edit-dialog.spec.ts @@ -0,0 +1,321 @@ +import { type Page, expect, test } from '@playwright/test'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { nanoid } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; + +import { + type TEnvelopeEditorSurface, + addEnvelopeItemPdf, + clickAddMyselfButton, + clickEnvelopeEditorStep, + getEnvelopeEditorSettingsTrigger, + openDocumentEnvelopeEditor, + openEmbeddedEnvelopeEditor, + openTemplateEnvelopeEditor, + persistEmbeddedEnvelope, + setRecipientEmail, + setRecipientName, +} from '../fixtures/envelope-editor'; +import { expectToastTextToBeVisible } from '../fixtures/generic'; + +test.use({ + storageState: { + cookies: [], + origins: [], + }, +}); + +const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf')); + +const multiPagePdfBuffer = fs.readFileSync( + path.join(__dirname, '../../../../assets/field-font-alignment.pdf'), +); + +// --- Shared helpers --- + +const openSettingsDialog = async (root: Page) => { + await getEnvelopeEditorSettingsTrigger(root).click(); + await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible(); +}; + +const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => { + await openSettingsDialog(surface.root); + await surface.root.locator('input[name="externalId"]').fill(externalId); + await surface.root.getByRole('button', { name: 'Update' }).click(); + + if (!surface.isEmbedded) { + await expectToastTextToBeVisible(surface.root, 'Envelope updated'); + } +}; + +const getEditButton = (root: Page, index: number) => + root.locator('[data-testid^="envelope-item-edit-button-"]').nth(index); + +const getEditDialog = (root: Page) => root.getByRole('dialog'); + +const getEditDialogTitleInput = (root: Page) => + root.locator('[data-testid="envelope-item-edit-title-input"]'); + +const getEditDialogDropzone = (root: Page) => + root.locator('[data-testid="envelope-item-edit-dropzone"]'); + +const getEditDialogSelectedFile = (root: Page) => + root.locator('[data-testid="envelope-item-edit-selected-file"]'); + +const getEditDialogClearFileButton = (root: Page) => + root.locator('[data-testid="envelope-item-edit-clear-file"]'); + +const getEditDialogUpdateButton = (root: Page) => + root.locator('[data-testid="envelope-item-edit-update-button"]'); + +const assertPdfPageCount = async (root: Page, expectedCount: number) => { + await expect(root.locator('[data-pdf-content]').first()).toHaveAttribute( + 'data-page-count', + String(expectedCount), + { timeout: 15000 }, + ); +}; + +const navigateToFieldsPage = async (surface: TEnvelopeEditorSurface) => { + // Set up a recipient first so the fields page is functional. + if (surface.isEmbedded) { + await setRecipientEmail(surface.root, 0, `test-${nanoid(4)}@example.com`); + await setRecipientName(surface.root, 0, 'Test User'); + } else { + await clickAddMyselfButton(surface.root); + } + + await clickEnvelopeEditorStep(surface.root, 'addFields'); + + // Wait for the file selector to be visible on the fields page. + await expect(getEditButton(surface.root, 0)).toBeAttached({ timeout: 10000 }); +}; + +const openEditDialogOnFieldsPage = async (root: Page, index = 0) => { + // Hover to reveal the edit button, then click it. + const editButton = getEditButton(root, index); + await editButton.click({ force: true }); + await expect(getEditDialog(root)).toBeVisible(); +}; + +// --- Flows --- + +const runRenameFlow = async (surface: TEnvelopeEditorSurface) => { + const externalId = `e2e-edit-rename-${nanoid()}`; + + if (surface.isEmbedded && !surface.envelopeId) { + await addEnvelopeItemPdf(surface.root, 'rename-test.pdf'); + } + + await updateExternalId(surface, externalId); + await navigateToFieldsPage(surface); + + // Open edit dialog and change the title. + await openEditDialogOnFieldsPage(surface.root); + + const titleInput = getEditDialogTitleInput(surface.root); + await expect(titleInput).toBeVisible(); + + await titleInput.clear(); + await titleInput.fill('Renamed Document'); + + // Update button should be disabled without a replacement file. + await expect(getEditDialogUpdateButton(surface.root)).toBeDisabled(); + + // A replacement file is required for the Update button to be enabled. + const dropzone = getEditDialogDropzone(surface.root); + await expect(dropzone).toBeVisible(); + + const fileInput = dropzone.locator('input[type="file"]'); + await fileInput.setInputFiles({ + name: 'example.pdf', + mimeType: 'application/pdf', + buffer: examplePdfBuffer, + }); + + await expect(getEditDialogSelectedFile(surface.root)).toBeVisible(); + + await getEditDialogUpdateButton(surface.root).click(); + + // Dialog should close. + await expect(getEditDialog(surface.root)).not.toBeVisible({ timeout: 10000 }); + + return { externalId }; +}; + +const runReplacePdfFlow = async (surface: TEnvelopeEditorSurface) => { + const externalId = `e2e-edit-replace-${nanoid()}`; + + if (surface.isEmbedded && !surface.envelopeId) { + await addEnvelopeItemPdf(surface.root, 'replace-test.pdf'); + } + + await updateExternalId(surface, externalId); + await navigateToFieldsPage(surface); + + // First, assert the page count is 1 (example.pdf has 1 page). + await assertPdfPageCount(surface.root, 1); + + // Open edit dialog. + await openEditDialogOnFieldsPage(surface.root); + + // Select a multi-page PDF via the dropzone. + const dropzone = getEditDialogDropzone(surface.root); + await expect(dropzone).toBeVisible(); + + const fileInput = dropzone.locator('input[type="file"]'); + await fileInput.setInputFiles({ + name: 'field-font-alignment.pdf', + mimeType: 'application/pdf', + buffer: multiPagePdfBuffer, + }); + + // Verify file is shown in the selected file display. + await expect(getEditDialogSelectedFile(surface.root)).toBeVisible(); + + // Click Update. + await getEditDialogUpdateButton(surface.root).click(); + + // Dialog should close. + await expect(getEditDialog(surface.root)).not.toBeVisible({ timeout: 15000 }); + + // After replacement, the page count should be 3 (field-font-alignment.pdf has 3 pages). + await assertPdfPageCount(surface.root, 3); + + return { externalId }; +}; + +const runClearFileSelectionFlow = async (surface: TEnvelopeEditorSurface) => { + if (surface.isEmbedded && !surface.envelopeId) { + await addEnvelopeItemPdf(surface.root, 'clear-file-test.pdf'); + } + + await navigateToFieldsPage(surface); + + // Open edit dialog. + await openEditDialogOnFieldsPage(surface.root); + + // Select a file. + const dropzone = getEditDialogDropzone(surface.root); + await expect(dropzone).toBeVisible(); + + const fileInput = dropzone.locator('input[type="file"]'); + await fileInput.setInputFiles({ + name: 'to-be-cleared.pdf', + mimeType: 'application/pdf', + buffer: examplePdfBuffer, + }); + + // Verify file appears. + await expect(getEditDialogSelectedFile(surface.root)).toBeVisible(); + await expect(getEditDialogDropzone(surface.root)).not.toBeVisible(); + + // Click X to clear. + await getEditDialogClearFileButton(surface.root).click(); + + // Dropzone should reappear. + await expect(getEditDialogDropzone(surface.root)).toBeVisible(); + await expect(getEditDialogSelectedFile(surface.root)).not.toBeVisible(); +}; + +// --- DB assertion --- + +const assertRenamePersistedInDatabase = async ({ + surface, + externalId, +}: { + surface: TEnvelopeEditorSurface; + externalId: string; +}) => { + const envelope = await prisma.envelope.findFirstOrThrow({ + where: { + externalId, + userId: surface.userId, + teamId: surface.teamId, + type: surface.envelopeType, + }, + include: { + envelopeItems: { + orderBy: { order: 'asc' }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + expect(envelope.envelopeItems.length).toBeGreaterThanOrEqual(1); + expect(envelope.envelopeItems[0].title).toBe('Renamed Document'); +}; + +// --- Tests --- + +test.describe('document editor', () => { + test('should rename an envelope item from the fields page', async ({ page }) => { + const surface = await openDocumentEnvelopeEditor(page); + const result = await runRenameFlow(surface); + + await assertRenamePersistedInDatabase({ surface, ...result }); + }); + + test('should replace a PDF from the fields page', async ({ page }) => { + const surface = await openDocumentEnvelopeEditor(page); + await runReplacePdfFlow(surface); + }); + + test('should clear a selected file before submitting', async ({ page }) => { + const surface = await openDocumentEnvelopeEditor(page); + await runClearFileSelectionFlow(surface); + }); +}); + +test.describe('template editor', () => { + test('should rename an envelope item from the fields page', async ({ page }) => { + const surface = await openTemplateEnvelopeEditor(page); + const result = await runRenameFlow(surface); + + await assertRenamePersistedInDatabase({ surface, ...result }); + }); +}); + +test.describe('embedded create', () => { + test('should rename an envelope item from the fields page', async ({ page }) => { + const surface = await openEmbeddedEnvelopeEditor(page, { + envelopeType: 'DOCUMENT', + tokenNamePrefix: 'e2e-edit-dialog-create', + }); + + const result = await runRenameFlow(surface); + + await clickEnvelopeEditorStep(surface.root, 'upload'); + await persistEmbeddedEnvelope(surface); + + await assertRenamePersistedInDatabase({ surface, ...result }); + }); + + test('should replace a PDF from the fields page', async ({ page }) => { + const surface = await openEmbeddedEnvelopeEditor(page, { + envelopeType: 'DOCUMENT', + tokenNamePrefix: 'e2e-edit-dialog-replace', + }); + + await runReplacePdfFlow(surface); + }); +}); + +test.describe('embedded edit', () => { + test('should rename an envelope item from the fields page', async ({ page }) => { + const surface = await openEmbeddedEnvelopeEditor(page, { + envelopeType: 'TEMPLATE', + mode: 'edit', + tokenNamePrefix: 'e2e-edit-dialog-edit', + }); + + const result = await runRenameFlow(surface); + + await clickEnvelopeEditorStep(surface.root, 'upload'); + await persistEmbeddedEnvelope(surface); + + await assertRenamePersistedInDatabase({ surface, ...result }); + }); +}); diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-replace-pdf.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-replace-pdf.spec.ts new file mode 100644 index 000000000..d06406b2f --- /dev/null +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-replace-pdf.spec.ts @@ -0,0 +1,532 @@ +import { type Page, expect, test } from '@playwright/test'; +import { FieldType } from '@prisma/client'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { nanoid } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; + +import { + type TEnvelopeEditorSurface, + addEnvelopeItemPdf, + clickAddMyselfButton, + clickEnvelopeEditorStep, + getEnvelopeEditorSettingsTrigger, + getEnvelopeItemDropzoneInput, + getEnvelopeItemReplaceButtons, + openDocumentEnvelopeEditor, + openEmbeddedEnvelopeEditor, + openTemplateEnvelopeEditor, + persistEmbeddedEnvelope, + setRecipientEmail, + setRecipientName, +} from '../fixtures/envelope-editor'; +import { expectToastTextToBeVisible } from '../fixtures/generic'; +import { getKonvaElementCountForPage } from '../fixtures/konva'; + +test.use({ + storageState: { + cookies: [], + origins: [], + }, +}); + +type TestFilePayload = { + name: string; + mimeType: string; + buffer: Buffer; +}; + +const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf')); + +const multiPagePdfBuffer = fs.readFileSync( + path.join(__dirname, '../../../../assets/field-font-alignment.pdf'), +); + +const createPdfPayload = (name: string, buffer: Buffer = examplePdfBuffer): TestFilePayload => ({ + name, + mimeType: 'application/pdf', + buffer, +}); + +// --- Shared helpers --- + +const openSettingsDialog = async (root: Page) => { + await getEnvelopeEditorSettingsTrigger(root).click(); + await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible(); +}; + +const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => { + await openSettingsDialog(surface.root); + await surface.root.locator('input[name="externalId"]').fill(externalId); + await surface.root.getByRole('button', { name: 'Update' }).click(); + + if (!surface.isEmbedded) { + await expectToastTextToBeVisible(surface.root, 'Envelope updated'); + } +}; + +const replaceEnvelopeItemPdf = async ( + root: Page, + index: number, + file: TestFilePayload, + options?: { isEmbedded?: boolean }, +) => { + const replaceButton = getEnvelopeItemReplaceButtons(root).nth(index); + await expect(replaceButton).toBeVisible(); + + // Listen for the file chooser event before clicking so the native dialog + // is intercepted and never actually shown to the user. + const [fileChooser] = await Promise.all([ + root.waitForEvent('filechooser'), + replaceButton.click(), + ]); + + await fileChooser.setFiles(file); + + // The button stays in the DOM but becomes disabled while the replace + // mutation is in flight, then re-enables once the mutation completes. + // Wait for both transitions to guarantee the replace has fully finished. + // + // For embedded surfaces the replacement is purely local state with no + // network round-trip, so it can complete before Playwright observes the + // disabled state. Skip the disabled assertion for embedded surfaces. + if (!options?.isEmbedded) { + await expect(replaceButton).toBeDisabled({ timeout: 15000 }); + } + + await expect(replaceButton).toBeEnabled({ timeout: 15000 }); +}; + +const assertPdfPageCount = async (root: Page, expectedCount: number) => { + await expect(root.locator('[data-pdf-content]').first()).toHaveAttribute( + 'data-page-count', + String(expectedCount), + { timeout: 15000 }, + ); +}; + +const placeFieldOnPdf = async ( + root: Page, + fieldName: 'Signature' | 'Text', + position: { x: number; y: number }, +) => { + await root.getByRole('button', { name: fieldName, exact: true }).click(); + + const canvas = root.locator('.konva-container canvas').first(); + await expect(canvas).toBeVisible(); + await canvas.click({ position }); +}; + +const scrollToPage = async (root: Page, pageNumber: number) => { + const pageImage = root.locator(`img[data-page-number="${pageNumber}"]`); + await pageImage.scrollIntoViewIfNeeded(); + await expect(pageImage).toBeVisible({ timeout: 10000 }); + // Wait for the Konva stage to initialize after virtualized rendering. + await root.waitForTimeout(1000); +}; + +const placeFieldOnPage = async ( + root: Page, + pageNumber: number, + fieldName: 'Signature' | 'Text', + position: { x: number; y: number }, +) => { + await root.getByRole('button', { name: fieldName, exact: true }).click(); + + if (pageNumber > 1) { + await scrollToPage(root, pageNumber); + } + + // Find the canvas corresponding to this page number via Konva stages. + // Since the virtualized list may have multiple canvases, we target the one + // that belongs to the correct page by using the evaluate approach. + const canvas = root.locator('.konva-container canvas'); + + if (pageNumber === 1) { + await expect(canvas.first()).toBeVisible(); + await canvas.first().click({ position }); + } else { + // For multi-page, find the canvas at the page position. + // The canvases are rendered in page order within the viewport. + // After scrolling to page N, it should be visible. We use nth based on + // the page index among currently rendered canvases. + // A more reliable approach: click on the page's img and offset into canvas. + const pageImg = root.locator(`img[data-page-number="${pageNumber}"]`); + const pageBox = await pageImg.boundingBox(); + + if (!pageBox) { + throw new Error(`Could not find bounding box for page ${pageNumber}`); + } + + // Click at the desired position relative to the page image. + // The Konva canvas overlays the image, so clicking at the same coordinates works. + await root.mouse.click(pageBox.x + position.x, pageBox.y + position.y); + } +}; + +// --- Test 1: Basic replace flow --- + +type BasicReplaceFlowResult = { + externalId: string; + originalDocumentDataId: string | null; +}; + +const runBasicReplaceFlow = async ( + surface: TEnvelopeEditorSurface, +): Promise => { + const { root, isEmbedded } = surface; + const externalId = `e2e-replace-${nanoid()}`; + + // For embedded create, upload a PDF first. + if (isEmbedded && !surface.envelopeId) { + await addEnvelopeItemPdf(root, 'replace-test-original.pdf'); + } + + await updateExternalId(surface, externalId); + + // Record the original documentDataId so we can assert it changed after replace. + // For embedded flows the externalId only lives in client state until persist, + // so query by envelope ID when available, and skip entirely for embedded create. + let originalDocumentDataId: string | null = null; + + if (!isEmbedded) { + const envelope = await prisma.envelope.findFirstOrThrow({ + where: { + externalId, + userId: surface.userId, + teamId: surface.teamId, + type: surface.envelopeType, + }, + include: { + envelopeItems: { orderBy: { order: 'asc' } }, + }, + }); + + originalDocumentDataId = envelope.envelopeItems[0].documentDataId; + } else if (surface.envelopeId) { + const envelope = await prisma.envelope.findFirstOrThrow({ + where: { + id: surface.envelopeId, + }, + include: { + envelopeItems: { orderBy: { order: 'asc' } }, + }, + }); + + originalDocumentDataId = envelope.envelopeItems[0].documentDataId; + } + + // Navigate to addFields step to verify initial page count. + await clickEnvelopeEditorStep(root, 'addFields'); + await root.locator('[data-pdf-content]').first().waitFor({ state: 'visible', timeout: 15000 }); + await assertPdfPageCount(root, 1); + + // Navigate back to upload step. + await clickEnvelopeEditorStep(root, 'upload'); + await expect(root.getByRole('heading', { name: 'Documents' })).toBeVisible(); + + // Replace the PDF. + await replaceEnvelopeItemPdf(root, 0, createPdfPayload('replace-test-new.pdf'), { isEmbedded }); + + // Navigate to addFields step to verify the PDF loaded correctly. + await clickEnvelopeEditorStep(root, 'addFields'); + await root.locator('[data-pdf-content]').first().waitFor({ state: 'visible', timeout: 15000 }); + await assertPdfPageCount(root, 1); + + // Navigate back to upload step. + await clickEnvelopeEditorStep(root, 'upload'); + await expect(root.getByRole('heading', { name: 'Documents' })).toBeVisible(); + + return { externalId, originalDocumentDataId }; +}; + +const assertBasicReplaceInDatabase = async ({ + surface, + externalId, + originalDocumentDataId, +}: { + surface: TEnvelopeEditorSurface; + externalId: string; + originalDocumentDataId: string | null; +}) => { + const envelope = await prisma.envelope.findFirstOrThrow({ + where: { + externalId, + userId: surface.userId, + teamId: surface.teamId, + type: surface.envelopeType, + }, + include: { + envelopeItems: { orderBy: { order: 'asc' } }, + }, + orderBy: { createdAt: 'desc' }, + }); + + expect(envelope.envelopeItems).toHaveLength(1); + + const item = envelope.envelopeItems[0]; + + // The documentDataId should have changed after replace. + if (originalDocumentDataId) { + expect(item.documentDataId).not.toBe(originalDocumentDataId); + } + + // Verify documentDataId is a valid non-empty string. + expect(item.documentDataId).toBeTruthy(); + expect(typeof item.documentDataId).toBe('string'); + + // Title and order should be unchanged. + expect(item.order).toBeGreaterThanOrEqual(1); +}; + +// --- Test 2: Field cleanup replace flow --- + +const TEST_FIELD_VALUES = { + embeddedRecipient: { + email: 'embedded-replace-recipient@documenso.com', + name: 'Embedded Replace Recipient', + }, +}; + +type FieldCleanupFlowResult = { + externalId: string; + recipientEmail: string; +}; + +const setupRecipient = async (surface: TEnvelopeEditorSurface): Promise => { + if (surface.isEmbedded) { + await setRecipientEmail(surface.root, 0, TEST_FIELD_VALUES.embeddedRecipient.email); + await setRecipientName(surface.root, 0, TEST_FIELD_VALUES.embeddedRecipient.name); + return TEST_FIELD_VALUES.embeddedRecipient.email; + } + + await clickAddMyselfButton(surface.root); + return surface.userEmail; +}; + +const uploadMultiPagePdf = async (root: Page) => { + await getEnvelopeItemDropzoneInput(root).setInputFiles({ + name: 'multi-page.pdf', + mimeType: 'application/pdf', + buffer: multiPagePdfBuffer, + }); +}; + +const runFieldCleanupReplaceFlow = async ( + surface: TEnvelopeEditorSurface, +): Promise => { + const { root, isEmbedded } = surface; + const externalId = `e2e-replace-fields-${nanoid()}`; + + // Step 1: Get a 3-page PDF loaded. + if (isEmbedded && !surface.envelopeId) { + // Embedded create: upload the multi-page PDF directly. + await uploadMultiPagePdf(root); + } else { + // All other surfaces: replace the existing 1-page PDF with the 3-page one. + await replaceEnvelopeItemPdf(root, 0, createPdfPayload('multi-page.pdf', multiPagePdfBuffer), { + isEmbedded, + }); + } + + await updateExternalId(surface, externalId); + + // Step 2: Add a recipient. + const recipientEmail = await setupRecipient(surface); + + // Step 3: Navigate to addFields step and verify 3-page PDF is loaded. + await clickEnvelopeEditorStep(root, 'addFields'); + await root.locator('[data-pdf-content]').first().waitFor({ state: 'visible', timeout: 15000 }); + await assertPdfPageCount(root, 3); + + // Step 4: Place a Signature field on page 1. + await placeFieldOnPdf(root, 'Signature', { x: 120, y: 140 }); + let fieldCountPage1 = await getKonvaElementCountForPage(root, 1, '.field-group'); + expect(fieldCountPage1).toBe(1); + + // Step 5: Scroll to page 2 and place a Text field. + await placeFieldOnPage(root, 2, 'Text', { x: 120, y: 140 }); + const fieldCountPage2 = await getKonvaElementCountForPage(root, 2, '.field-group'); + expect(fieldCountPage2).toBe(1); + + // Verify file selector shows "2 Fields". + await expect(root.getByText('2 Fields')).toBeVisible(); + + // Step 6: Navigate back to upload step. + await clickEnvelopeEditorStep(root, 'upload'); + await expect(root.getByRole('heading', { name: 'Documents' })).toBeVisible(); + + // Step 7: Replace with 1-page PDF. + await replaceEnvelopeItemPdf(root, 0, createPdfPayload('single-page.pdf'), { isEmbedded }); + + // Step 8: Navigate to addFields step to verify. + await clickEnvelopeEditorStep(root, 'addFields'); + await root.locator('[data-pdf-content]').first().waitFor({ state: 'visible', timeout: 15000 }); + + // PDF should now be 1 page. + await assertPdfPageCount(root, 1); + + // Page 1 field should survive. + fieldCountPage1 = await getKonvaElementCountForPage(root, 1, '.field-group'); + expect(fieldCountPage1).toBe(1); + + // File selector should show "1 Field". + await expect(root.getByText('1 Field')).toBeVisible(); + + return { externalId, recipientEmail }; +}; + +const assertFieldCleanupInDatabase = async ({ + surface, + externalId, + recipientEmail, +}: { + surface: TEnvelopeEditorSurface; + externalId: string; + recipientEmail: string; +}) => { + const envelope = await prisma.envelope.findFirstOrThrow({ + where: { + externalId, + userId: surface.userId, + teamId: surface.teamId, + type: surface.envelopeType, + }, + include: { + fields: true, + recipients: true, + envelopeItems: { orderBy: { order: 'asc' } }, + }, + orderBy: { createdAt: 'desc' }, + }); + + const recipient = envelope.recipients.find((r) => r.email === recipientEmail); + expect(recipient).toBeDefined(); + + // Only the page-1 field should remain. + expect(envelope.fields).toHaveLength(1); + expect(envelope.fields[0].page).toBe(1); + expect(envelope.fields[0].type).toBe(FieldType.SIGNATURE); + expect(envelope.fields[0].recipientId).toBe(recipient?.id); + + // The envelope item should have a 1-page PDF (documentDataId should exist). + expect(envelope.envelopeItems).toHaveLength(1); + expect(envelope.envelopeItems[0].documentDataId).toBeTruthy(); +}; + +// --- Test describe blocks --- + +test.describe('document editor', () => { + test('replace PDF on an envelope item', async ({ page }) => { + const surface = await openDocumentEnvelopeEditor(page); + const result = await runBasicReplaceFlow(surface); + + await assertBasicReplaceInDatabase({ + surface, + ...result, + }); + }); + + test('replace PDF deletes fields on out-of-bounds pages', async ({ page }) => { + const surface = await openDocumentEnvelopeEditor(page); + const result = await runFieldCleanupReplaceFlow(surface); + + await assertFieldCleanupInDatabase({ + surface, + ...result, + }); + }); +}); + +test.describe('template editor', () => { + test('replace PDF on an envelope item', async ({ page }) => { + const surface = await openTemplateEnvelopeEditor(page); + const result = await runBasicReplaceFlow(surface); + + await assertBasicReplaceInDatabase({ + surface, + ...result, + }); + }); + + test('replace PDF deletes fields on out-of-bounds pages', async ({ page }) => { + const surface = await openTemplateEnvelopeEditor(page); + const result = await runFieldCleanupReplaceFlow(surface); + + await assertFieldCleanupInDatabase({ + surface, + ...result, + }); + }); +}); + +test.describe('embedded create', () => { + test('replace PDF on an envelope item', async ({ page }) => { + const surface = await openEmbeddedEnvelopeEditor(page, { + envelopeType: 'DOCUMENT', + tokenNamePrefix: 'e2e-embed-replace', + }); + + const result = await runBasicReplaceFlow(surface); + + await persistEmbeddedEnvelope(surface); + + await assertBasicReplaceInDatabase({ + surface, + ...result, + }); + }); + + test('replace PDF deletes fields on out-of-bounds pages', async ({ page }) => { + const surface = await openEmbeddedEnvelopeEditor(page, { + envelopeType: 'DOCUMENT', + tokenNamePrefix: 'e2e-embed-replace-fields', + }); + + const result = await runFieldCleanupReplaceFlow(surface); + + await persistEmbeddedEnvelope(surface); + + await assertFieldCleanupInDatabase({ + surface, + ...result, + }); + }); +}); + +test.describe('embedded edit', () => { + test('replace PDF on an envelope item', async ({ page }) => { + const surface = await openEmbeddedEnvelopeEditor(page, { + envelopeType: 'TEMPLATE', + mode: 'edit', + tokenNamePrefix: 'e2e-embed-replace', + }); + + const result = await runBasicReplaceFlow(surface); + + await persistEmbeddedEnvelope(surface); + + await assertBasicReplaceInDatabase({ + surface, + ...result, + }); + }); + + test('replace PDF deletes fields on out-of-bounds pages', async ({ page }) => { + const surface = await openEmbeddedEnvelopeEditor(page, { + envelopeType: 'TEMPLATE', + mode: 'edit', + tokenNamePrefix: 'e2e-embed-replace-fields', + }); + + const result = await runFieldCleanupReplaceFlow(surface); + + await persistEmbeddedEnvelope(surface); + + await assertFieldCleanupInDatabase({ + surface, + ...result, + }); + }); +}); diff --git a/packages/app-tests/e2e/fixtures/envelope-editor.ts b/packages/app-tests/e2e/fixtures/envelope-editor.ts index 64ecd75b5..4e3bff962 100644 --- a/packages/app-tests/e2e/fixtures/envelope-editor.ts +++ b/packages/app-tests/e2e/fixtures/envelope-editor.ts @@ -247,6 +247,9 @@ export const getEnvelopeItemDragHandles = (root: Page) => export const getEnvelopeItemRemoveButtons = (root: Page) => root.locator('[data-testid^="envelope-item-remove-button-"]'); +export const getEnvelopeItemReplaceButtons = (root: Page) => + root.locator('[data-testid^="envelope-item-replace-button-"]'); + export const getEnvelopeItemDropzoneInput = (root: Page) => root.locator('[data-testid="envelope-item-dropzone"] input[type="file"]'); diff --git a/packages/lib/jobs/definitions/internal/seal-document.handler.ts b/packages/lib/jobs/definitions/internal/seal-document.handler.ts index 5d44b7147..061b5866d 100644 --- a/packages/lib/jobs/definitions/internal/seal-document.handler.ts +++ b/packages/lib/jobs/definitions/internal/seal-document.handler.ts @@ -491,7 +491,7 @@ const decorateAndSignPdf = async ({ // Add suffix based on document status const suffix = isRejected ? '_rejected.pdf' : '_signed.pdf'; - const newDocumentData = await putPdfFileServerSide( + const { documentData: newDocumentData } = await putPdfFileServerSide( { name: `${name}${suffix}`, type: 'application/pdf', diff --git a/packages/lib/server-only/envelope-item/create-envelope-items.ts b/packages/lib/server-only/envelope-item/create-envelope-items.ts index 8d40eb9aa..150eefe12 100644 --- a/packages/lib/server-only/envelope-item/create-envelope-items.ts +++ b/packages/lib/server-only/envelope-item/create-envelope-items.ts @@ -61,7 +61,7 @@ export const UNSAFE_createEnvelopeItems = async ({ const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized); - const { id: documentDataId } = await putPdfFileServerSide({ + const { documentData } = await putPdfFileServerSide({ name: file.name, type: 'application/pdf', arrayBuffer: async () => Promise.resolve(cleanedPdf), @@ -71,7 +71,7 @@ export const UNSAFE_createEnvelopeItems = async ({ id: prefixedId('envelope_item'), title: file.name, clientId, - documentDataId, + documentDataId: documentData.id, placeholders, order: orderOverride ?? currentHighestOrderValue + index + 1, }; diff --git a/packages/lib/server-only/envelope-item/replace-envelope-item-pdf.ts b/packages/lib/server-only/envelope-item/replace-envelope-item-pdf.ts new file mode 100644 index 000000000..25a329383 --- /dev/null +++ b/packages/lib/server-only/envelope-item/replace-envelope-item-pdf.ts @@ -0,0 +1,233 @@ +import type { Envelope, Field, Recipient } from '@prisma/client'; + +import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf'; +import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; +import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server'; +import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; +import { prisma } from '@documenso/prisma'; + +import { convertPlaceholdersToFieldInputs, extractPdfPlaceholders } from '../pdf/auto-place-fields'; +import { findRecipientByPlaceholder } from '../pdf/helpers'; +import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf'; + +type UnsafeReplaceEnvelopeItemPdfOptions = { + envelope: Pick; + + /** + * Recipients used to resolve placeholder field assignments. + * When provided and placeholders are found in the replacement PDF, + * fields will be auto-created for matching recipients. + */ + recipients: Recipient[]; + + /** + * The ID of the envelope item which we will be replacing the PDF for. + */ + envelopeItemId: string; + + /** + * The ID of the old document data we will be deleting. + */ + oldDocumentDataId: string; + + /** + * The data we will be replacing. + */ + data: { + title?: string; + order?: number; + file: File; + }; + + user: { + id: number; + name: string | null; + email: string; + }; + apiRequestMetadata: ApiRequestMetadata; +}; + +type UnsafeReplaceEnvelopeItemPdfResult = { + updatedItem: { + id: string; + title: string; + envelopeId: string; + order: number; + documentDataId: string; + }; + + /** + * The full list of fields for the envelope after the replacement. + * + * Only returned when fields were created or deleted during the replacement, + * otherwise `undefined`. + */ + fields: Field[] | undefined; +}; + +export const UNSAFE_replaceEnvelopeItemPdf = async ({ + envelope, + recipients, + envelopeItemId, + oldDocumentDataId, + data, + user, + apiRequestMetadata, +}: UnsafeReplaceEnvelopeItemPdfOptions): Promise => { + let buffer = Buffer.from(await data.file.arrayBuffer()); + + if (envelope.formValues) { + buffer = await insertFormValuesInPdf({ pdf: buffer, formValues: envelope.formValues }); + } + + const normalized = await normalizePdf(buffer, { + flattenForm: envelope.type !== 'TEMPLATE', + }); + + const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized); + + // Upload the new PDF and get a new DocumentData record. + const { documentData: newDocumentData, filePageCount } = await putPdfFileServerSide({ + name: data.file.name, + type: 'application/pdf', + arrayBuffer: async () => Promise.resolve(cleanedPdf), + }); + + let didFieldsChange = false; + + const updatedEnvelopeItem = await prisma.$transaction(async (tx) => { + const updatedItem = await tx.envelopeItem.update({ + where: { + id: envelopeItemId, + envelopeId: envelope.id, + }, + data: { + documentDataId: newDocumentData.id, + title: data.title, + order: data.order, + }, + }); + + // Todo: Audit log if we're updating the title or order. + + // Delete fields that reference pages beyond the new PDF's page count. + const outOfBoundsFields = await tx.field.findMany({ + where: { + envelopeId: envelope.id, + envelopeItemId, + page: { + gt: filePageCount, + }, + }, + select: { + id: true, + }, + }); + + const deletedFieldIds = outOfBoundsFields.map((f) => f.id); + + if (deletedFieldIds.length > 0) { + await tx.field.deleteMany({ + where: { + id: { + in: deletedFieldIds, + }, + }, + }); + + didFieldsChange = true; + } + + if (recipients.length > 0 && placeholders.length > 0) { + const orderedRecipients = [...recipients].sort((a, b) => { + const aOrder = a.signingOrder ?? Number.MAX_SAFE_INTEGER; + const bOrder = b.signingOrder ?? Number.MAX_SAFE_INTEGER; + + if (aOrder !== bOrder) { + return aOrder - bOrder; + } + + return a.id - b.id; + }); + + const fieldsToCreate = convertPlaceholdersToFieldInputs( + placeholders, + (recipientPlaceholder, placeholder) => + findRecipientByPlaceholder( + recipientPlaceholder, + placeholder, + orderedRecipients, + orderedRecipients, + ), + updatedItem.id, + ); + + if (fieldsToCreate.length > 0) { + await tx.field.createMany({ + data: fieldsToCreate.map((field) => ({ + envelopeId: envelope.id, + envelopeItemId: updatedItem.id, + recipientId: field.recipientId, + type: field.type, + page: field.page, + positionX: field.positionX, + positionY: field.positionY, + width: field.width, + height: field.height, + customText: '', + inserted: false, + fieldMeta: field.fieldMeta || undefined, + })), + }); + + didFieldsChange = true; + } + } + + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_PDF_REPLACED, + envelopeId: envelope.id, + data: { + envelopeItemId: updatedItem.id, + envelopeItemTitle: updatedItem.title, + }, + user: { + name: user.name, + email: user.email, + }, + requestMetadata: apiRequestMetadata.requestMetadata, + }), + }); + + return updatedItem; + }); + + // Delete the old DocumentData (now orphaned). + await prisma.documentData.delete({ + where: { + id: oldDocumentDataId, + }, + }); + + let fields: Field[] | undefined = undefined; + + if (didFieldsChange) { + try { + fields = await prisma.field.findMany({ + where: { + envelopeId: envelope.id, + }, + }); + } catch (err) { + // Do nothing. + console.error(err); + } + } + + return { + updatedItem: updatedEnvelopeItem, + fields, + }; +}; diff --git a/packages/lib/server-only/envelope/create-envelope.ts b/packages/lib/server-only/envelope/create-envelope.ts index 3a37c321f..9793ddcad 100644 --- a/packages/lib/server-only/envelope/create-envelope.ts +++ b/packages/lib/server-only/envelope/create-envelope.ts @@ -207,7 +207,7 @@ export const createEnvelope = async ({ const titleToUse = item.title || title; - const newDocumentData = await putPdfFileServerSide({ + const { documentData: newDocumentData } = await putPdfFileServerSide({ name: titleToUse, type: 'application/pdf', arrayBuffer: async () => Promise.resolve(normalizedPdf), diff --git a/packages/lib/server-only/field/create-envelope-fields.ts b/packages/lib/server-only/field/create-envelope-fields.ts index f593bf510..d6eae9d9d 100644 --- a/packages/lib/server-only/field/create-envelope-fields.ts +++ b/packages/lib/server-only/field/create-envelope-fields.ts @@ -308,7 +308,7 @@ export const createEnvelopeFields = async ({ continue; } - const newDocumentData = await putPdfFileServerSide({ + const { documentData: newDocumentData } = await putPdfFileServerSide({ name: 'document.pdf', type: 'application/pdf', arrayBuffer: async () => Promise.resolve(Buffer.from(modifiedPdfBytes)), diff --git a/packages/lib/server-only/template/create-document-from-direct-template.ts b/packages/lib/server-only/template/create-document-from-direct-template.ts index 1260cb21d..81a3059b5 100644 --- a/packages/lib/server-only/template/create-document-from-direct-template.ts +++ b/packages/lib/server-only/template/create-document-from-direct-template.ts @@ -307,20 +307,12 @@ export const createDocumentFromDirectTemplate = async ({ const titleToUse = item.title || directTemplateEnvelope.title; - const duplicatedFile = await putPdfFileServerSide({ + const { documentData: newDocumentData } = await putPdfFileServerSide({ name: titleToUse, type: 'application/pdf', arrayBuffer: async () => Promise.resolve(buffer), }); - const newDocumentData = await prisma.documentData.create({ - data: { - type: duplicatedFile.type, - data: duplicatedFile.data, - initialData: duplicatedFile.initialData, - }, - }); - const newEnvelopeItemId = prefixedId('envelope_item'); oldEnvelopeItemToNewEnvelopeItemIdMap[item.id] = newEnvelopeItemId; diff --git a/packages/lib/types/document-audit-logs.ts b/packages/lib/types/document-audit-logs.ts index 3eb518d5c..df6de4765 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_PDF_REPLACED', // Document events. 'DOCUMENT_COMPLETED', // When the document is sealed and fully completed. @@ -209,6 +210,17 @@ export const ZDocumentAuditLogEventEnvelopeItemDeletedSchema = z.object({ }), }); +/** + * Event: Envelope item PDF replaced. + */ +export const ZDocumentAuditLogEventEnvelopeItemPdfReplacedSchema = z.object({ + type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_PDF_REPLACED), + data: z.object({ + envelopeItemId: z.string(), + envelopeItemTitle: z.string(), + }), +}); + /** * Event: Email sent. */ @@ -722,6 +734,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and( z.union([ ZDocumentAuditLogEventEnvelopeItemCreatedSchema, ZDocumentAuditLogEventEnvelopeItemDeletedSchema, + ZDocumentAuditLogEventEnvelopeItemPdfReplacedSchema, ZDocumentAuditLogEventEmailSentSchema, ZDocumentAuditLogEventDocumentCompletedSchema, ZDocumentAuditLogEventDocumentCreatedSchema, diff --git a/packages/lib/types/envelope-editor.ts b/packages/lib/types/envelope-editor.ts index d36d40acb..66a355391 100644 --- a/packages/lib/types/envelope-editor.ts +++ b/packages/lib/types/envelope-editor.ts @@ -71,6 +71,7 @@ export const ZEnvelopeEditorSettingsSchema = z.object({ allowConfigureOrder: z.boolean(), allowUpload: z.boolean(), allowDelete: z.boolean(), + allowReplace: z.boolean(), }) .nullable(), @@ -136,6 +137,7 @@ export const DEFAULT_EDITOR_CONFIG: EnvelopeEditorConfig = { allowConfigureOrder: true, allowUpload: true, allowDelete: true, + allowReplace: true, }, recipients: { allowAIDetection: true, @@ -192,6 +194,7 @@ export const DEFAULT_EMBEDDED_EDITOR_CONFIG = { allowConfigureOrder: true, allowUpload: true, allowDelete: true, + allowReplace: true, }, recipients: { allowAIDetection: false, // These are not supported for embeds, and are directly excluded in the embedded repo. diff --git a/packages/lib/universal/upload/put-file.server.ts b/packages/lib/universal/upload/put-file.server.ts index f9950d3f8..0593e8a19 100644 --- a/packages/lib/universal/upload/put-file.server.ts +++ b/packages/lib/universal/upload/put-file.server.ts @@ -41,7 +41,12 @@ export const putPdfFileServerSide = async (file: File, initialData?: string) => const { type, data } = await putFileServerSide(file); - return await createDocumentData({ type, data, initialData }); + const createdData = await createDocumentData({ type, data, initialData }); + + return { + documentData: createdData, + filePageCount: pdf.getPageCount(), + }; }; /** diff --git a/packages/lib/utils/document-audit-logs.ts b/packages/lib/utils/document-audit-logs.ts index 5067752cf..c89ca05f6 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_PDF_REPLACED }, ({ data }) => ({ + anonymous: msg({ + message: `Envelope item PDF replaced`, + context: `Audit log format`, + }), + you: msg`You replaced the PDF for envelope item ${data.envelopeItemTitle}`, + user: msg`${user} replaced the PDF for envelope item ${data.envelopeItemTitle}`, + })) .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRED }, ({ data }) => ({ anonymous: msg({ message: `Recipient signing window expired`, diff --git a/packages/lib/utils/embed-config.ts b/packages/lib/utils/embed-config.ts index e32516c74..132d12433 100644 --- a/packages/lib/utils/embed-config.ts +++ b/packages/lib/utils/embed-config.ts @@ -110,6 +110,9 @@ export const buildEmbeddedFeatures = ( allowDelete: features.envelopeItems?.allowDelete ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowDelete, + allowReplace: + features.envelopeItems?.allowReplace ?? + DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowReplace, } : null, diff --git a/packages/trpc/server/document-router/create-document.types.ts b/packages/trpc/server/document-router/create-document.types.ts index 1155a17c7..960700e1c 100644 --- a/packages/trpc/server/document-router/create-document.types.ts +++ b/packages/trpc/server/document-router/create-document.types.ts @@ -18,7 +18,7 @@ import { } from '@documenso/lib/types/field'; import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta'; -import { zodFormData } from '../../utils/zod-form-data'; +import { zfdFile, zodFormData } from '../../utils/zod-form-data'; import { ZCreateRecipientSchema } from '../recipient-router/schema'; import type { TrpcRouteMeta } from '../trpc'; import { ZDocumentExternalIdSchema, ZDocumentTitleSchema } from './schema'; @@ -79,7 +79,7 @@ export const ZCreateDocumentPayloadSchema = z.object({ export const ZCreateDocumentRequestSchema = zodFormData({ payload: zfd.json(ZCreateDocumentPayloadSchema), - file: zfd.file(), + file: zfdFile(), }); export const ZCreateDocumentResponseSchema = z.object({ diff --git a/packages/trpc/server/embedding-router/update-embedding-envelope.ts b/packages/trpc/server/embedding-router/update-embedding-envelope.ts index dc5c42ff1..c9408c729 100644 --- a/packages/trpc/server/embedding-router/update-embedding-envelope.ts +++ b/packages/trpc/server/embedding-router/update-embedding-envelope.ts @@ -6,6 +6,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token'; import { UNSAFE_createEnvelopeItems } from '@documenso/lib/server-only/envelope-item/create-envelope-items'; import { UNSAFE_deleteEnvelopeItem } from '@documenso/lib/server-only/envelope-item/delete-envelope-item'; +import { UNSAFE_replaceEnvelopeItemPdf } from '@documenso/lib/server-only/envelope-item/replace-envelope-item-pdf'; 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 { updateEnvelope } from '@documenso/lib/server-only/envelope/update-envelope'; @@ -95,8 +96,9 @@ export const updateEmbeddingEnvelopeRoute = procedure // Step 1: Update the envelope items. const envelopeItemsToUpdate: EnvelopeItemUpdateOptions[] = []; const envelopeItemsToCreate: EnvelopeItemCreateOptions[] = []; + const envelopeItemsToReplace: EnvelopeItemReplaceOptions[] = []; - // Sort and group envelope items to update and create. + // Sort and group envelope items to update, create, and replace. data.envelopeItems.forEach((item) => { const isNewEnvelopeItem = item.id.startsWith(PRESIGNED_ENVELOPE_ITEM_ID_PREFIX); @@ -112,6 +114,27 @@ export const updateEmbeddingEnvelopeRoute = procedure }); } + // Check if this existing item has a replacement file. + if (item.replaceFileIndex !== undefined) { + const replaceFile = files[item.replaceFileIndex]; + + if (!replaceFile) { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: 'Invalid replace file index', + }); + } + + envelopeItemsToReplace.push({ + envelopeItemId: envelopeItem.id, + oldDocumentDataId: envelopeItem.documentDataId, + title: item.title, + order: item.order, + file: replaceFile, + }); + + return; + } + const hasEnvelopeItemChanged = envelopeItem.title !== item.title || envelopeItem.order !== item.order; @@ -152,7 +175,8 @@ export const updateEmbeddingEnvelopeRoute = procedure const willEnvelopeItemsBeModified = envelopeItemIdsToDelete.length > 0 || envelopeItemsToCreate.length > 0 || - envelopeItemsToUpdate.length > 0; + envelopeItemsToUpdate.length > 0 || + envelopeItemsToReplace.length > 0; const organisationClaim = envelope.team.organisation.organisationClaim; const resultingEnvelopeItemCount = @@ -232,6 +256,30 @@ export const updateEmbeddingEnvelopeRoute = procedure }); } + // Replace PDFs for existing envelope items without creating placeholder fields + // field cleanup is handled in later steps. + if (envelopeItemsToReplace.length > 0) { + await pMap( + envelopeItemsToReplace, + async (item) => { + await UNSAFE_replaceEnvelopeItemPdf({ + envelope, + recipients: [], + envelopeItemId: item.envelopeItemId, + oldDocumentDataId: item.oldDocumentDataId, + data: { + title: item.title, + order: item.order, + file: item.file, + }, + user: apiToken.user, + apiRequestMetadata: ctx.metadata, + }); + }, + { concurrency: 2 }, + ); + } + // Step 2: Update the general envelope data and meta. await updateEnvelope({ userId: apiToken.userId, @@ -426,3 +474,11 @@ type EnvelopeItemCreateOptions = { order: number; file: File; }; + +type EnvelopeItemReplaceOptions = { + envelopeItemId: string; + oldDocumentDataId: string; + title: string; + order: number; + file: File; +}; diff --git a/packages/trpc/server/embedding-router/update-embedding-envelope.types.ts b/packages/trpc/server/embedding-router/update-embedding-envelope.types.ts index cb3fb4a39..cd6ce7d66 100644 --- a/packages/trpc/server/embedding-router/update-embedding-envelope.types.ts +++ b/packages/trpc/server/embedding-router/update-embedding-envelope.types.ts @@ -17,7 +17,7 @@ import { ZEnvelopeFieldAndMetaSchema } from '@documenso/lib/types/field-meta'; import { EnvelopeAttachmentSchema } from '@documenso/prisma/generated/zod/modelSchema/EnvelopeAttachmentSchema'; import { ZSetEnvelopeRecipientSchema } from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types'; -import { zodFormData } from '../../utils/zod-form-data'; +import { zfdFile, zodFormData } from '../../utils/zod-form-data'; import { ZDocumentExternalIdSchema, ZDocumentTitleSchema, @@ -60,6 +60,16 @@ export const ZUpdateEmbeddingEnvelopePayloadSchema = z.object({ * The file index for items that are not yet uploaded. */ index: z.number().int().min(0).optional(), + + /** + * The file index for existing items that need their PDF replaced. + * Only applicable to items with real IDs (not PRESIGNED_ prefix). + */ + replaceFileIndex: z.number().int().min(0).optional(), + }) + .refine((item) => !(item.index !== undefined && item.replaceFileIndex !== undefined), { + message: 'Cannot provide both index and replaceFileIndex on the same envelope item', + path: ['replaceFileIndex'], }) .array(), @@ -102,7 +112,7 @@ export const ZUpdateEmbeddingEnvelopePayloadSchema = z.object({ export const ZUpdateEmbeddingEnvelopeRequestSchema = zodFormData({ payload: zfd.json(ZUpdateEmbeddingEnvelopePayloadSchema), - files: zfd.repeatableOfType(zfd.file()), + files: zfd.repeatableOfType(zfdFile()), }); export const ZUpdateEmbeddingEnvelopeResponseSchema = z.void(); diff --git a/packages/trpc/server/envelope-router/create-envelope-items.types.ts b/packages/trpc/server/envelope-router/create-envelope-items.types.ts index 9bd42560c..76fbb778d 100644 --- a/packages/trpc/server/envelope-router/create-envelope-items.types.ts +++ b/packages/trpc/server/envelope-router/create-envelope-items.types.ts @@ -3,7 +3,7 @@ import { zfd } from 'zod-form-data'; import EnvelopeItemSchema from '@documenso/prisma/generated/zod/modelSchema/EnvelopeItemSchema'; -import { zodFormData } from '../../utils/zod-form-data'; +import { zfdFile, zodFormData } from '../../utils/zod-form-data'; import type { TrpcRouteMeta } from '../trpc'; export const createEnvelopeItemsMeta: TrpcRouteMeta = { @@ -24,7 +24,7 @@ export const ZCreateEnvelopeItemsPayloadSchema = z.object({ export const ZCreateEnvelopeItemsRequestSchema = zodFormData({ payload: zfd.json(ZCreateEnvelopeItemsPayloadSchema), - files: zfd.repeatableOfType(zfd.file()), + files: zfd.repeatableOfType(zfdFile()), }); export const ZCreateEnvelopeItemsResponseSchema = z.object({ diff --git a/packages/trpc/server/envelope-router/create-envelope.ts b/packages/trpc/server/envelope-router/create-envelope.ts index 8667e914a..8f10ef62f 100644 --- a/packages/trpc/server/envelope-router/create-envelope.ts +++ b/packages/trpc/server/envelope-router/create-envelope.ts @@ -124,7 +124,7 @@ export const createEnvelopeRouteCaller = async ({ // Todo: Embeds - Might need to add this for client-side embeds in the future. const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized); - const { id: documentDataId } = await putPdfFileServerSide({ + const { documentData } = await putPdfFileServerSide({ name: file.name, type: 'application/pdf', arrayBuffer: async () => Promise.resolve(cleanedPdf), @@ -132,7 +132,7 @@ export const createEnvelopeRouteCaller = async ({ return { title: file.name, - documentDataId, + documentDataId: documentData.id, placeholders, }; }), diff --git a/packages/trpc/server/envelope-router/create-envelope.types.ts b/packages/trpc/server/envelope-router/create-envelope.types.ts index 7c9102224..ed823bde6 100644 --- a/packages/trpc/server/envelope-router/create-envelope.types.ts +++ b/packages/trpc/server/envelope-router/create-envelope.types.ts @@ -18,7 +18,7 @@ import { } from '@documenso/lib/types/field'; import { ZEnvelopeFieldAndMetaSchema } from '@documenso/lib/types/field-meta'; -import { zodFormData } from '../../utils/zod-form-data'; +import { zfdFile, zodFormData } from '../../utils/zod-form-data'; import { ZDocumentExternalIdSchema, ZDocumentTitleSchema, @@ -94,7 +94,7 @@ export const ZCreateEnvelopePayloadSchema = z.object({ export const ZCreateEnvelopeRequestSchema = zodFormData({ payload: zfd.json(ZCreateEnvelopePayloadSchema), - files: zfd.repeatableOfType(zfd.file()), + files: zfd.repeatableOfType(zfdFile()), }); export const ZCreateEnvelopeResponseSchema = z.object({ diff --git a/packages/trpc/server/envelope-router/replace-envelope-item-pdf.ts b/packages/trpc/server/envelope-router/replace-envelope-item-pdf.ts new file mode 100644 index 000000000..68bb8878c --- /dev/null +++ b/packages/trpc/server/envelope-router/replace-envelope-item-pdf.ts @@ -0,0 +1,103 @@ +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 { prisma } from '@documenso/prisma'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZReplaceEnvelopeItemPdfRequestSchema, + ZReplaceEnvelopeItemPdfResponseSchema, +} from './replace-envelope-item-pdf.types'; + +/** + * Keep this internal for the envelope editor. + * + * If we want to make this public then create a separate one that only allows + * the PDF to be replaced & doesn't return deleted fields, etc. + */ +export const replaceEnvelopeItemPdfRoute = authenticatedProcedure + .input(ZReplaceEnvelopeItemPdfRequestSchema) + .output(ZReplaceEnvelopeItemPdfResponseSchema) + .mutation(async ({ input, ctx }) => { + const { user, teamId, metadata } = ctx; + const { payload, file } = input; + const { envelopeId, envelopeItemId, title } = payload; + + ctx.logger.info({ + input: { + envelopeId, + envelopeItemId, + }, + }); + + const { envelopeWhereInput } = await getEnvelopeWhereInput({ + id: { + type: 'envelopeId', + id: envelopeId, + }, + type: null, + userId: user.id, + teamId, + }); + + const envelope = await prisma.envelope.findUnique({ + where: envelopeWhereInput, + include: { + recipients: true, + envelopeItems: { + orderBy: { + order: 'asc', + }, + }, + }, + }); + + if (!envelope) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Envelope not found', + }); + } + + if (envelope.internalVersion !== 2) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'PDF replacement is only supported for version 2 envelopes', + }); + } + + if (!canEnvelopeItemsBeModified(envelope, envelope.recipients)) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Envelope item is not editable', + }); + } + + const envelopeItem = envelope.envelopeItems.find((item) => item.id === envelopeItemId); + + if (!envelopeItem) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Envelope item not found', + }); + } + + const { updatedItem, fields } = await UNSAFE_replaceEnvelopeItemPdf({ + envelope, + recipients: envelope.recipients, + envelopeItemId, + oldDocumentDataId: envelopeItem.documentDataId, + data: { + file, + title, + }, + user: { + id: user.id, + name: user.name, + email: user.email, + }, + apiRequestMetadata: metadata, + }); + + return { + data: updatedItem, + fields, + }; + }); diff --git a/packages/trpc/server/envelope-router/replace-envelope-item-pdf.types.ts b/packages/trpc/server/envelope-router/replace-envelope-item-pdf.types.ts new file mode 100644 index 000000000..ec3fddf51 --- /dev/null +++ b/packages/trpc/server/envelope-router/replace-envelope-item-pdf.types.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; +import { zfd } from 'zod-form-data'; + +import { ZEnvelopeFieldSchema } from '@documenso/lib/types/field'; +import EnvelopeItemSchema from '@documenso/prisma/generated/zod/modelSchema/EnvelopeItemSchema'; + +import { zfdFile, zodFormData } from '../../utils/zod-form-data'; +import { ZDocumentTitleSchema } from '../document-router/schema'; + +export const ZReplaceEnvelopeItemPdfPayloadSchema = z.object({ + envelopeId: z.string(), + envelopeItemId: z.string(), + title: ZDocumentTitleSchema.optional(), +}); + +export const ZReplaceEnvelopeItemPdfRequestSchema = zodFormData({ + payload: zfd.json(ZReplaceEnvelopeItemPdfPayloadSchema), + file: zfdFile(), +}); + +export const ZReplaceEnvelopeItemPdfResponseSchema = z.object({ + data: EnvelopeItemSchema.pick({ + id: true, + title: true, + envelopeId: true, + order: true, + documentDataId: true, + }), + /** + * The full list of fields for the envelope after the replacement. + * + * This is only populated if fields have been changed or deleted. It will + * return undefined otherwise. + * + * Done this way to reduce number of queries. + */ + fields: ZEnvelopeFieldSchema.array().optional(), +}); + +export type TReplaceEnvelopeItemPdfPayload = z.infer; +export type TReplaceEnvelopeItemPdfRequest = z.infer; +export type TReplaceEnvelopeItemPdfResponse = z.infer; diff --git a/packages/trpc/server/envelope-router/router.ts b/packages/trpc/server/envelope-router/router.ts index aa5be5dc0..6b52196eb 100644 --- a/packages/trpc/server/envelope-router/router.ts +++ b/packages/trpc/server/envelope-router/router.ts @@ -28,6 +28,7 @@ import { getEnvelopeItemsRoute } from './get-envelope-items'; import { getEnvelopeItemsByTokenRoute } from './get-envelope-items-by-token'; import { getEnvelopesByIdsRoute } from './get-envelopes-by-ids'; import { redistributeEnvelopeRoute } from './redistribute-envelope'; +import { replaceEnvelopeItemPdfRoute } from './replace-envelope-item-pdf'; import { setEnvelopeFieldsRoute } from './set-envelope-fields'; import { setEnvelopeRecipientsRoute } from './set-envelope-recipients'; import { signEnvelopeFieldRoute } from './sign-envelope-field'; @@ -55,6 +56,7 @@ export const envelopeRouter = router({ updateMany: updateEnvelopeItemsRoute, delete: deleteEnvelopeItemRoute, download: downloadEnvelopeItemRoute, + replacePdf: replaceEnvelopeItemPdfRoute, }, recipient: { get: getEnvelopeRecipientRoute, diff --git a/packages/trpc/server/envelope-router/use-envelope.types.ts b/packages/trpc/server/envelope-router/use-envelope.types.ts index 84b334763..36e53a105 100644 --- a/packages/trpc/server/envelope-router/use-envelope.types.ts +++ b/packages/trpc/server/envelope-router/use-envelope.types.ts @@ -20,7 +20,7 @@ import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-att import { ZFieldMetaPrefillFieldsSchema } from '@documenso/lib/types/field-meta'; import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient'; -import { zodFormData } from '../../utils/zod-form-data'; +import { zfdFile, zodFormData } from '../../utils/zod-form-data'; import type { TrpcRouteMeta } from '../trpc'; import { ZRecipientWithSigningUrlSchema } from './schema'; @@ -117,7 +117,7 @@ export const ZUseEnvelopePayloadSchema = z.object({ export const ZUseEnvelopeRequestSchema = zodFormData({ payload: zfd.json(ZUseEnvelopePayloadSchema), - files: zfd.repeatableOfType(zfd.file()).optional(), + files: zfd.repeatableOfType(zfdFile()).optional(), }); export const ZUseEnvelopeResponseSchema = z.object({ diff --git a/packages/trpc/server/template-router/schema.ts b/packages/trpc/server/template-router/schema.ts index 0196169c3..95c948b0e 100644 --- a/packages/trpc/server/template-router/schema.ts +++ b/packages/trpc/server/template-router/schema.ts @@ -35,7 +35,7 @@ import { import { LegacyTemplateDirectLinkSchema } from '@documenso/prisma/types/template-legacy-schema'; import { ZDocumentExternalIdSchema } from '@documenso/trpc/server/document-router/schema'; -import { zodFormData } from '../../utils/zod-form-data'; +import { zfdFile, zodFormData } from '../../utils/zod-form-data'; import { ZSignFieldWithTokenMutationSchema } from '../field-router/schema'; export const MAX_TEMPLATE_PUBLIC_TITLE_LENGTH = 50; @@ -267,7 +267,7 @@ export const ZCreateTemplatePayloadSchema = ZCreateTemplateV2RequestSchema; export const ZCreateTemplateMutationSchema = zodFormData({ payload: zfd.json(ZCreateTemplatePayloadSchema), - file: zfd.file(), + file: zfdFile(), }); export const ZUpdateTemplateRequestSchema = z.object({ diff --git a/packages/trpc/utils/zod-form-data.ts b/packages/trpc/utils/zod-form-data.ts index 0b78bdb82..fe75b74bc 100644 --- a/packages/trpc/utils/zod-form-data.ts +++ b/packages/trpc/utils/zod-form-data.ts @@ -1,5 +1,22 @@ import type { ZodRawShape } from 'zod'; import z from 'zod'; +import { zfd } from 'zod-form-data'; + +import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; +import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions'; + +/** + * A `zfd.file()` schema with a max file size constraint based on + * `APP_DOCUMENT_UPLOAD_SIZE_LIMIT`. Use this instead of bare `zfd.file()` + * to ensure server-side file size validation. + */ +export const zfdFile = () => { + const maxBytes = megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT); + + return zfd.file().refine((file) => file.size <= maxBytes, { + message: `File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`, + }); +}; /** * This helper takes the place of the `z.object` at the root of your schema. diff --git a/packages/ui/lib/handle-dropzone-rejection.tsx b/packages/ui/lib/handle-dropzone-rejection.tsx new file mode 100644 index 000000000..749ef009f --- /dev/null +++ b/packages/ui/lib/handle-dropzone-rejection.tsx @@ -0,0 +1,19 @@ +import { msg } from '@lingui/core/macro'; +import { ErrorCode, type FileRejection } from 'react-dropzone'; +import { match } from 'ts-pattern'; + +import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; + +export const buildDropzoneRejectionDescription = (fileRejections: FileRejection[]) => { + const errorCode = fileRejections[0]?.errors[0]?.code; + + return match(errorCode) + .with( + ErrorCode.FileTooLarge, + () => msg`File is larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`, + ) + .with(ErrorCode.FileInvalidType, () => msg`Only PDF files are allowed`) + .with(ErrorCode.FileTooSmall, () => msg`File is too small`) + .with(ErrorCode.TooManyFiles, () => msg`Only one file can be uploaded at a time`) + .otherwise(() => msg`Unknown error`); +};