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..0c2998c2a 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 @@ -20,6 +20,7 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org import { useSession } from '@documenso/lib/client-only/providers/session'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; +import { ALLOWED_UPLOAD_MIME_TYPES } from '@documenso/lib/constants/upload'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions'; import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; @@ -132,6 +133,18 @@ export const EnvelopeDropZoneWrapper = ({ 'ENVELOPE_ITEM_LIMIT_EXCEEDED', () => t`You have reached the limit of the number of files per envelope.`, ) + .with( + 'CONVERSION_SERVICE_UNAVAILABLE', + () => t`File conversion is not available. Please upload a PDF file.`, + ) + .with( + 'CONVERSION_FAILED', + () => t`Failed to convert file. Please try uploading a PDF instead.`, + ) + .with( + 'UNSUPPORTED_FILE_TYPE', + () => t`This file type is not supported. Please upload a PDF, Word document, or image.`, + ) .otherwise(() => t`An error occurred during upload.`); toast({ @@ -180,7 +193,9 @@ export const EnvelopeDropZoneWrapper = ({ .with(ErrorCode.FileTooLarge, () => ( File is larger than {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB )) - .with(ErrorCode.FileInvalidType, () => Only PDF files are allowed) + .with(ErrorCode.FileInvalidType, () => ( + Supported formats: PDF, Word documents, JPEG, and PNG images + )) .with(ErrorCode.FileTooSmall, () => File is too small) .with(ErrorCode.TooManyFiles, () => ( Only one file can be uploaded at a time @@ -208,9 +223,7 @@ export const EnvelopeDropZoneWrapper = ({ }); }; const { getRootProps, getInputProps, isDragActive } = useDropzone({ - accept: { - 'application/pdf': ['.pdf'], - }, + accept: ALLOWED_UPLOAD_MIME_TYPES, multiple: true, maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT), maxFiles: maximumEnvelopeItemCount, @@ -237,7 +250,7 @@ export const EnvelopeDropZoneWrapper = ({

- Drag and drop your PDF file here + Drag and drop your document here

{isUploadDisabled && IS_BILLING_ENABLED() && ( diff --git a/apps/remix/server/api/files/files.helpers.ts b/apps/remix/server/api/files/files.helpers.ts index 17fdc53b4..b8a8bb3e8 100644 --- a/apps/remix/server/api/files/files.helpers.ts +++ b/apps/remix/server/api/files/files.helpers.ts @@ -2,6 +2,7 @@ import { type DocumentDataType, DocumentStatus } from '@prisma/client'; import contentDisposition from 'content-disposition'; import { type Context } from 'hono'; +import { getFileExtensionForMimeType } from '@documenso/lib/constants/upload'; import { sha256 } from '@documenso/lib/universal/crypto'; import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server'; @@ -14,6 +15,8 @@ type HandleEnvelopeItemFileRequestOptions = { type: DocumentDataType; data: string; initialData: string; + originalData?: string | null; + originalMimeType?: string | null; }; version: 'signed' | 'original'; isDownload: boolean; @@ -31,7 +34,21 @@ export const handleEnvelopeItemFileRequest = async ({ isDownload, context: c, }: HandleEnvelopeItemFileRequestOptions) => { - const documentDataToUse = version === 'signed' ? documentData.data : documentData.initialData; + const shouldServeOriginalSourceFile = + version === 'original' && + documentData.originalData && + documentData.originalMimeType && + documentData.originalMimeType !== 'application/pdf'; + + const documentDataToUse = shouldServeOriginalSourceFile + ? documentData.originalData! + : version === 'signed' + ? documentData.data + : documentData.initialData; + + const contentType = shouldServeOriginalSourceFile + ? documentData.originalMimeType! + : 'application/pdf'; const etag = Buffer.from(sha256(documentDataToUse)).toString('hex'); @@ -52,7 +69,7 @@ export const handleEnvelopeItemFileRequest = async ({ return c.json({ error: 'File not found' }, 404); } - c.header('Content-Type', 'application/pdf'); + c.header('Content-Type', contentType); c.header('ETag', etag); if (!isDownload) { @@ -64,10 +81,17 @@ export const handleEnvelopeItemFileRequest = async ({ } if (isDownload) { - // Generate filename following the pattern from envelope-download-dialog.tsx - const baseTitle = title.replace(/\.pdf$/, ''); - const suffix = version === 'signed' ? '_signed.pdf' : '.pdf'; - const filename = `${baseTitle}${suffix}`; + const baseTitle = title.replace(/\.[^/.]+$/, ''); + + let filename: string; + if (version === 'signed') { + filename = `${baseTitle}_signed.pdf`; + } else if (shouldServeOriginalSourceFile) { + const extension = getFileExtensionForMimeType(documentData.originalMimeType!); + filename = `${baseTitle}${extension}`; + } else { + filename = `${baseTitle}.pdf`; + } c.header('Content-Disposition', contentDisposition(filename)); diff --git a/assets/test-document.docx b/assets/test-document.docx new file mode 100644 index 000000000..56db7a6d4 Binary files /dev/null and b/assets/test-document.docx differ diff --git a/assets/test-image.jpg b/assets/test-image.jpg new file mode 100644 index 000000000..57df2c25c Binary files /dev/null and b/assets/test-image.jpg differ diff --git a/assets/test-image.png b/assets/test-image.png new file mode 100644 index 000000000..b5c140480 Binary files /dev/null and b/assets/test-image.png differ diff --git a/packages/app-tests/e2e/envelopes/file-conversion.spec.ts b/packages/app-tests/e2e/envelopes/file-conversion.spec.ts new file mode 100644 index 000000000..d602ddb03 --- /dev/null +++ b/packages/app-tests/e2e/envelopes/file-conversion.spec.ts @@ -0,0 +1,1160 @@ +import type { Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; +import { DocumentStatus, EnvelopeType, FieldType } from '@prisma/client'; +import { nanoid } from 'nanoid'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { prisma } from '@documenso/prisma'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '../../../lib/constants/app'; +import { createApiToken } from '../../../lib/server-only/public-api/create-api-token'; +import { RecipientRole } from '../../../prisma/generated/types'; +import type { + TCreateEnvelopePayload, + TCreateEnvelopeResponse, +} from '../../../trpc/server/envelope-router/create-envelope.types'; +import type { TDistributeEnvelopeRequest } from '../../../trpc/server/envelope-router/distribute-envelope.types'; +import { apiSignin } from '../fixtures/authentication'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); +const baseUrl = `${WEBAPP_BASE_URL}/api/v2`; + +test.describe.configure({ mode: 'parallel', timeout: 60000 }); + +/** + * Helper to verify originalData is populated for converted files. + * PNG/JPEG/DOCX should have originalData, PDF should not. + */ +async function verifyOriginalData(envelopeId: string, expectOriginalData: boolean) { + const envelope = await prisma.envelope.findUniqueOrThrow({ + where: { id: envelopeId }, + include: { + envelopeItems: { + include: { documentData: true }, + }, + }, + }); + + if (expectOriginalData) { + expect(envelope.envelopeItems[0].documentData?.originalData).not.toBeNull(); + expect(envelope.envelopeItems[0].documentData?.originalMimeType).not.toBeNull(); + } else { + expect(envelope.envelopeItems[0].documentData?.originalData).toBeNull(); + } +} + +/** + * Helper to mark signature fields as inserted via Prisma. + * This bypasses Konva canvas interaction which is hard to test. + * Based on the pattern used in envelope-alignment.spec.ts. + */ +async function markSignatureFieldsAsInserted(envelopeId: string, recipientId: number) { + const signatureFields = await prisma.field.findMany({ + where: { + envelopeId, + recipientId, + type: FieldType.SIGNATURE, + inserted: false, + }, + }); + + for (const field of signatureFields) { + await prisma.field.update({ + where: { id: field.id }, + data: { + inserted: true, + customText: '', + signature: { + create: { + recipientId, + typedSignature: 'Test Signature', + signatureImageAsBase64: null, + }, + }, + }, + }); + } +} + +/** + * Helper to complete the signing flow for a recipient. + * Expects fields to already be marked as inserted via Prisma. + */ +async function completeSigningFlow(page: Page) { + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + + // Wait for Complete button to be visible (fields should already be inserted) + await expect(page.getByRole('button', { name: 'Complete' })).toBeVisible({ timeout: 10000 }); + + // Click Complete button + await page.getByRole('button', { name: 'Complete' }).click(); + + // Wait for Sign button to be visible in the confirmation dialog + const signButton = page.getByRole('button', { name: 'Sign' }); + await expect(signButton).toBeVisible({ timeout: 10000 }); + + // Use force click to handle transient DOM changes + await signButton.click({ force: true }); + + // Wait for "Document Signed" heading to appear (confirms completion) + await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible({ + timeout: 30000, + }); +} + +test.describe('File Conversion', () => { + test.describe('API Tests', () => { + test('[FILE_CONVERSION_API]: should upload, convert, and sign a PNG image via API', async ({ + page, + request, + }) => { + const { user: uploader, team } = await seedUser(); + const { user: recipient } = await seedUser(); + + const { token } = await createApiToken({ + userId: uploader.id, + teamId: team.id, + tokenName: 'test', + expiresIn: null, + }); + + const pngFile = fs.readFileSync(path.join(__dirname, '../../../../assets/test-image.png')); + + const formData = new FormData(); + + const createEnvelopePayload: TCreateEnvelopePayload = { + type: EnvelopeType.DOCUMENT, + title: 'PNG Conversion Test', + recipients: [ + { + email: recipient.email, + name: recipient.name || '', + role: RecipientRole.SIGNER, + fields: [ + { + type: FieldType.SIGNATURE, + fieldMeta: { type: 'signature' }, + page: 1, + positionX: 10, + positionY: 10, + width: 20, + height: 5, + }, + ], + }, + ], + }; + + formData.append('payload', JSON.stringify(createEnvelopePayload)); + formData.append('files', new File([pngFile], 'test-image.png', { type: 'image/png' })); + + const createEnvelopeRequest = await request.post(`${baseUrl}/envelope/create`, { + headers: { Authorization: `Bearer ${token}` }, + multipart: formData, + }); + + if (!createEnvelopeRequest.ok()) { + const errorBody = await createEnvelopeRequest.json(); + if ( + errorBody.code === 'CONVERSION_SERVICE_UNAVAILABLE' || + errorBody.code === 'CONVERSION_FAILED' + ) { + test.skip(true, 'Gotenberg conversion service is not available'); + return; + } + throw new Error(`Failed to create envelope: ${JSON.stringify(errorBody)}`); + } + + expect(createEnvelopeRequest.status()).toBe(200); + + const { id: createdEnvelopeId }: TCreateEnvelopeResponse = await createEnvelopeRequest.json(); + + const envelope = await prisma.envelope.findUniqueOrThrow({ + where: { id: createdEnvelopeId }, + include: { + recipients: true, + envelopeItems: true, + }, + }); + + expect(envelope.envelopeItems.length).toBe(1); + expect(envelope.recipients.length).toBe(1); + + const distributeEnvelopeRequest = await request.post(`${baseUrl}/envelope/distribute`, { + headers: { Authorization: `Bearer ${token}` }, + data: { + envelopeId: envelope.id, + } satisfies TDistributeEnvelopeRequest, + }); + + expect(distributeEnvelopeRequest.ok()).toBeTruthy(); + + const recipientToken = envelope.recipients[0].token; + const signUrl = `/sign/${recipientToken}`; + + // Mark signature fields as inserted via Prisma BEFORE loading page + // (bypasses Konva canvas interaction) + await markSignatureFieldsAsInserted(envelope.id, envelope.recipients[0].id); + + await apiSignin({ + page, + email: recipient.email, + redirectPath: signUrl, + }); + + await completeSigningFlow(page); + + await expect(async () => { + const { status } = await prisma.envelope.findFirstOrThrow({ + where: { id: envelope.id }, + }); + expect(status).toBe(DocumentStatus.COMPLETED); + }).toPass({ timeout: 10000 }); + + await verifyOriginalData(envelope.id, true); + }); + + test('[FILE_CONVERSION_API]: should upload, convert, and sign a JPEG image via API', async ({ + page, + request, + }) => { + const { user: uploader, team } = await seedUser(); + const { user: recipient } = await seedUser(); + + const { token } = await createApiToken({ + userId: uploader.id, + teamId: team.id, + tokenName: 'test', + expiresIn: null, + }); + + const jpegFile = fs.readFileSync(path.join(__dirname, '../../../../assets/test-image.jpg')); + + const formData = new FormData(); + + const createEnvelopePayload: TCreateEnvelopePayload = { + type: EnvelopeType.DOCUMENT, + title: 'JPEG Conversion Test', + recipients: [ + { + email: recipient.email, + name: recipient.name || '', + role: RecipientRole.SIGNER, + fields: [ + { + type: FieldType.SIGNATURE, + fieldMeta: { type: 'signature' }, + page: 1, + positionX: 10, + positionY: 10, + width: 20, + height: 5, + }, + ], + }, + ], + }; + + formData.append('payload', JSON.stringify(createEnvelopePayload)); + formData.append('files', new File([jpegFile], 'test-image.jpg', { type: 'image/jpeg' })); + + const createEnvelopeRequest = await request.post(`${baseUrl}/envelope/create`, { + headers: { Authorization: `Bearer ${token}` }, + multipart: formData, + }); + + if (!createEnvelopeRequest.ok()) { + const errorBody = await createEnvelopeRequest.json(); + if ( + errorBody.code === 'CONVERSION_SERVICE_UNAVAILABLE' || + errorBody.code === 'CONVERSION_FAILED' + ) { + test.skip(true, 'Gotenberg conversion service is not available'); + return; + } + throw new Error(`Failed to create envelope: ${JSON.stringify(errorBody)}`); + } + + expect(createEnvelopeRequest.status()).toBe(200); + + const { id: createdEnvelopeId }: TCreateEnvelopeResponse = await createEnvelopeRequest.json(); + + const envelope = await prisma.envelope.findUniqueOrThrow({ + where: { id: createdEnvelopeId }, + include: { + recipients: true, + envelopeItems: true, + }, + }); + + expect(envelope.envelopeItems.length).toBe(1); + expect(envelope.recipients.length).toBe(1); + + const distributeEnvelopeRequest = await request.post(`${baseUrl}/envelope/distribute`, { + headers: { Authorization: `Bearer ${token}` }, + data: { + envelopeId: envelope.id, + } satisfies TDistributeEnvelopeRequest, + }); + + expect(distributeEnvelopeRequest.ok()).toBeTruthy(); + + const recipientToken = envelope.recipients[0].token; + const signUrl = `/sign/${recipientToken}`; + + // Mark signature fields as inserted via Prisma BEFORE loading page + // (bypasses Konva canvas interaction) + await markSignatureFieldsAsInserted(envelope.id, envelope.recipients[0].id); + + await apiSignin({ + page, + email: recipient.email, + redirectPath: signUrl, + }); + + await completeSigningFlow(page); + + await expect(async () => { + const { status } = await prisma.envelope.findFirstOrThrow({ + where: { id: envelope.id }, + }); + expect(status).toBe(DocumentStatus.COMPLETED); + }).toPass({ timeout: 10000 }); + + await verifyOriginalData(envelope.id, true); + }); + + test('[FILE_CONVERSION_API]: should upload, convert, and sign a DOCX document via API', async ({ + page, + request, + }) => { + const { user: uploader, team } = await seedUser(); + const { user: recipient } = await seedUser(); + + const { token } = await createApiToken({ + userId: uploader.id, + teamId: team.id, + tokenName: 'test', + expiresIn: null, + }); + + const docxFile = fs.readFileSync( + path.join(__dirname, '../../../../assets/test-document.docx'), + ); + + const formData = new FormData(); + + const createEnvelopePayload: TCreateEnvelopePayload = { + type: EnvelopeType.DOCUMENT, + title: 'DOCX Conversion Test', + recipients: [ + { + email: recipient.email, + name: recipient.name || '', + role: RecipientRole.SIGNER, + fields: [ + { + type: FieldType.SIGNATURE, + fieldMeta: { type: 'signature' }, + page: 1, + positionX: 10, + positionY: 10, + width: 20, + height: 5, + }, + ], + }, + ], + }; + + formData.append('payload', JSON.stringify(createEnvelopePayload)); + formData.append( + 'files', + new File([docxFile], 'test-document.docx', { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }), + ); + + const createEnvelopeRequest = await request.post(`${baseUrl}/envelope/create`, { + headers: { Authorization: `Bearer ${token}` }, + multipart: formData, + }); + + if (!createEnvelopeRequest.ok()) { + const errorBody = await createEnvelopeRequest.json(); + if ( + errorBody.code === 'CONVERSION_SERVICE_UNAVAILABLE' || + errorBody.code === 'CONVERSION_FAILED' + ) { + test.skip(true, 'Gotenberg conversion service is not available'); + return; + } + throw new Error(`Failed to create envelope: ${JSON.stringify(errorBody)}`); + } + + expect(createEnvelopeRequest.status()).toBe(200); + + const { id: createdEnvelopeId }: TCreateEnvelopeResponse = await createEnvelopeRequest.json(); + + const envelope = await prisma.envelope.findUniqueOrThrow({ + where: { id: createdEnvelopeId }, + include: { + recipients: true, + envelopeItems: true, + }, + }); + + expect(envelope.envelopeItems.length).toBe(1); + expect(envelope.recipients.length).toBe(1); + + const distributeEnvelopeRequest = await request.post(`${baseUrl}/envelope/distribute`, { + headers: { Authorization: `Bearer ${token}` }, + data: { + envelopeId: envelope.id, + } satisfies TDistributeEnvelopeRequest, + }); + + expect(distributeEnvelopeRequest.ok()).toBeTruthy(); + + const recipientToken = envelope.recipients[0].token; + const signUrl = `/sign/${recipientToken}`; + + // Mark signature fields as inserted via Prisma BEFORE loading page + // (bypasses Konva canvas interaction) + await markSignatureFieldsAsInserted(envelope.id, envelope.recipients[0].id); + + await apiSignin({ + page, + email: recipient.email, + redirectPath: signUrl, + }); + + await completeSigningFlow(page); + + await expect(async () => { + const { status } = await prisma.envelope.findFirstOrThrow({ + where: { id: envelope.id }, + }); + expect(status).toBe(DocumentStatus.COMPLETED); + }).toPass({ timeout: 10000 }); + + await verifyOriginalData(envelope.id, true); + }); + + test('[FILE_CONVERSION_API]: should upload and sign a PDF document via API (no conversion)', async ({ + page, + request, + }) => { + const { user: uploader, team } = await seedUser(); + const { user: recipient } = await seedUser(); + + const { token } = await createApiToken({ + userId: uploader.id, + teamId: team.id, + tokenName: 'test', + expiresIn: null, + }); + + const pdfFile = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf')); + + const formData = new FormData(); + + const createEnvelopePayload: TCreateEnvelopePayload = { + type: EnvelopeType.DOCUMENT, + title: 'PDF Upload Test', + recipients: [ + { + email: recipient.email, + name: recipient.name || '', + role: RecipientRole.SIGNER, + fields: [ + { + type: FieldType.SIGNATURE, + fieldMeta: { type: 'signature' }, + page: 1, + positionX: 10, + positionY: 10, + width: 20, + height: 5, + }, + ], + }, + ], + }; + + formData.append('payload', JSON.stringify(createEnvelopePayload)); + formData.append('files', new File([pdfFile], 'example.pdf', { type: 'application/pdf' })); + + const createEnvelopeRequest = await request.post(`${baseUrl}/envelope/create`, { + headers: { Authorization: `Bearer ${token}` }, + multipart: formData, + }); + + expect(createEnvelopeRequest.ok()).toBeTruthy(); + expect(createEnvelopeRequest.status()).toBe(200); + + const { id: createdEnvelopeId }: TCreateEnvelopeResponse = await createEnvelopeRequest.json(); + + const envelope = await prisma.envelope.findUniqueOrThrow({ + where: { id: createdEnvelopeId }, + include: { + recipients: true, + envelopeItems: true, + }, + }); + + expect(envelope.envelopeItems.length).toBe(1); + expect(envelope.recipients.length).toBe(1); + + const distributeEnvelopeRequest = await request.post(`${baseUrl}/envelope/distribute`, { + headers: { Authorization: `Bearer ${token}` }, + data: { + envelopeId: envelope.id, + } satisfies TDistributeEnvelopeRequest, + }); + + expect(distributeEnvelopeRequest.ok()).toBeTruthy(); + + const recipientToken = envelope.recipients[0].token; + const signUrl = `/sign/${recipientToken}`; + + // Mark signature fields as inserted via Prisma BEFORE loading page + // (bypasses Konva canvas interaction) + await markSignatureFieldsAsInserted(envelope.id, envelope.recipients[0].id); + + await apiSignin({ + page, + email: recipient.email, + redirectPath: signUrl, + }); + + await completeSigningFlow(page); + + await expect(async () => { + const { status } = await prisma.envelope.findFirstOrThrow({ + where: { id: envelope.id }, + }); + expect(status).toBe(DocumentStatus.COMPLETED); + }).toPass({ timeout: 10000 }); + + // PDF should NOT have originalData (no conversion needed) + await verifyOriginalData(envelope.id, false); + }); + + test('[FILE_CONVERSION_API]: should reject unsupported file types', async ({ request }) => { + const { user, team } = await seedUser(); + + const { token } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'test', + expiresIn: null, + }); + + const jsonFile = fs.readFileSync(path.join(__dirname, '../../../../package.json')); + + const formData = new FormData(); + + const createEnvelopePayload: TCreateEnvelopePayload = { + type: EnvelopeType.DOCUMENT, + title: 'Unsupported File Test', + recipients: [ + { + email: user.email, + name: user.name || '', + role: RecipientRole.SIGNER, + fields: [ + { + type: FieldType.SIGNATURE, + fieldMeta: { type: 'signature' }, + page: 1, + positionX: 10, + positionY: 10, + width: 20, + height: 5, + }, + ], + }, + ], + }; + + formData.append('payload', JSON.stringify(createEnvelopePayload)); + formData.append('files', new File([jsonFile], 'package.json', { type: 'application/json' })); + + const createEnvelopeRequest = await request.post(`${baseUrl}/envelope/create`, { + headers: { Authorization: `Bearer ${token}` }, + multipart: formData, + }); + + expect(createEnvelopeRequest.ok()).toBeFalsy(); + + const errorBody = await createEnvelopeRequest.json(); + // Accept either UNSUPPORTED_FILE_TYPE or INTERNAL_SERVER_ERROR as valid rejection + expect( + errorBody.code === 'UNSUPPORTED_FILE_TYPE' || errorBody.code === 'INTERNAL_SERVER_ERROR', + ).toBeTruthy(); + }); + + test('[FILE_CONVERSION_API]: should return error when Gotenberg is unavailable', async ({ + request, + }) => { + const { user, team } = await seedUser(); + + const { token } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'test', + expiresIn: null, + }); + + const pngFile = fs.readFileSync(path.join(__dirname, '../../../../assets/test-image.png')); + + const formData = new FormData(); + + const createEnvelopePayload: TCreateEnvelopePayload = { + type: EnvelopeType.DOCUMENT, + title: 'Gotenberg Error Test', + recipients: [ + { + email: user.email, + name: user.name || '', + role: RecipientRole.SIGNER, + fields: [ + { + type: FieldType.SIGNATURE, + fieldMeta: { type: 'signature' }, + page: 1, + positionX: 10, + positionY: 10, + width: 20, + height: 5, + }, + ], + }, + ], + }; + + formData.append('payload', JSON.stringify(createEnvelopePayload)); + formData.append('files', new File([pngFile], 'test-image.png', { type: 'image/png' })); + + const createEnvelopeRequest = await request.post(`${baseUrl}/envelope/create`, { + headers: { Authorization: `Bearer ${token}` }, + multipart: formData, + }); + + // This test only validates the error flow if Gotenberg is actually unavailable + if (createEnvelopeRequest.ok()) { + test.skip(true, 'Gotenberg is available - skipping error scenario test'); + return; + } + + const errorBody = await createEnvelopeRequest.json(); + expect( + errorBody.code === 'CONVERSION_SERVICE_UNAVAILABLE' || + errorBody.code === 'CONVERSION_FAILED', + ).toBeTruthy(); + }); + }); + + test.describe('UI Tests', () => { + test('[FILE_CONVERSION_UI]: should upload, convert, and sign a PNG image', async ({ + page, + request, + }) => { + const { user: uploader, team } = await seedUser(); + const { user: recipient } = await seedUser(); + + const { token } = await createApiToken({ + userId: uploader.id, + teamId: team.id, + tokenName: 'test', + expiresIn: null, + }); + + await apiSignin({ + page, + email: uploader.email, + redirectPath: `/t/${team.url}/documents`, + }); + + // Wait for documents page to load + await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible(); + + // Upload PNG via file input + // Use .first() because there are two upload inputs (dropzone wrapper + upload button) + const fileInput = page.getByTestId('document-upload-input').first(); + await fileInput.setInputFiles(path.join(__dirname, '../../../../assets/test-image.png')); + + // Wait for redirect to editor or error toast + try { + await page.waitForURL(/\/documents\/.*\/edit/, { timeout: 15000 }); + } catch { + // Check if error toast appeared (Gotenberg unavailable) + const errorToast = page.locator('[data-sonner-toast][data-type="error"]'); + if (await errorToast.isVisible()) { + test.skip(true, 'Gotenberg conversion service is not available'); + return; + } + throw new Error('Upload failed without expected error toast'); + } + + // Get envelope ID from URL + const urlMatch = page.url().match(/documents\/(.+?)\/edit/); + const envelopeId = urlMatch?.[1]; + expect(envelopeId).toBeDefined(); + + // Add recipient via API + const envelope = await prisma.envelope.findUniqueOrThrow({ + where: { id: envelopeId }, + include: { envelopeItems: true }, + }); + + const recipientRecord = await prisma.recipient.create({ + data: { + email: recipient.email, + name: recipient.name || '', + role: RecipientRole.SIGNER, + envelopeId: envelope.id, + token: nanoid(), + }, + }); + + // Add field via Prisma + await prisma.field.create({ + data: { + envelopeId: envelope.id, + recipientId: recipientRecord.id, + type: FieldType.SIGNATURE, + page: 1, + positionX: 10, + positionY: 10, + width: 20, + height: 5, + envelopeItemId: envelope.envelopeItems[0].id, + customText: '', + inserted: false, + fieldMeta: { type: 'signature' }, + }, + }); + + // Distribute envelope via API + const distributeEnvelopeRequest = await request.post(`${baseUrl}/envelope/distribute`, { + headers: { Authorization: `Bearer ${token}` }, + data: { + envelopeId: envelope.id, + } satisfies TDistributeEnvelopeRequest, + }); + + if (!distributeEnvelopeRequest.ok()) { + const errorBody = await distributeEnvelopeRequest.json(); + throw new Error(`Failed to distribute envelope: ${JSON.stringify(errorBody)}`); + } + + // Get updated recipient with token + const updatedRecipient = await prisma.recipient.findUniqueOrThrow({ + where: { id: recipientRecord.id }, + }); + + const signUrl = `/sign/${updatedRecipient.token}`; + + // Mark signature fields as inserted via Prisma BEFORE loading page + // (bypasses Konva canvas interaction) + await markSignatureFieldsAsInserted(envelope.id, recipientRecord.id); + + // Sign as recipient + await apiSignin({ + page, + email: recipient.email, + redirectPath: signUrl, + }); + + await completeSigningFlow(page); + + // Verify completion + await expect(async () => { + const { status } = await prisma.envelope.findFirstOrThrow({ + where: { id: envelope.id }, + }); + expect(status).toBe(DocumentStatus.COMPLETED); + }).toPass({ timeout: 10000 }); + + // PNG should have originalData + await verifyOriginalData(envelope.id, true); + }); + + test('[FILE_CONVERSION_UI]: should upload, convert, and sign a JPEG image', async ({ + page, + request, + }) => { + const { user: uploader, team } = await seedUser(); + const { user: recipient } = await seedUser(); + + const { token } = await createApiToken({ + userId: uploader.id, + teamId: team.id, + tokenName: 'test', + expiresIn: null, + }); + + await apiSignin({ + page, + email: uploader.email, + redirectPath: `/t/${team.url}/documents`, + }); + + await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible(); + + // Use .first() because there are two upload inputs (dropzone wrapper + upload button) + const fileInput = page.getByTestId('document-upload-input').first(); + await fileInput.setInputFiles(path.join(__dirname, '../../../../assets/test-image.jpg')); + + try { + await page.waitForURL(/\/documents\/.*\/edit/, { timeout: 15000 }); + } catch { + const errorToast = page.locator('[data-sonner-toast][data-type="error"]'); + if (await errorToast.isVisible()) { + test.skip(true, 'Gotenberg conversion service is not available'); + return; + } + throw new Error('Upload failed without expected error toast'); + } + + const urlMatch = page.url().match(/documents\/(.+?)\/edit/); + const envelopeId = urlMatch?.[1]; + expect(envelopeId).toBeDefined(); + + const envelope = await prisma.envelope.findUniqueOrThrow({ + where: { id: envelopeId }, + include: { envelopeItems: true }, + }); + + const recipientRecord = await prisma.recipient.create({ + data: { + email: recipient.email, + name: recipient.name || '', + role: RecipientRole.SIGNER, + envelopeId: envelope.id, + token: nanoid(), + }, + }); + + await prisma.field.create({ + data: { + envelopeId: envelope.id, + recipientId: recipientRecord.id, + type: FieldType.SIGNATURE, + page: 1, + positionX: 10, + positionY: 10, + width: 20, + height: 5, + envelopeItemId: envelope.envelopeItems[0].id, + customText: '', + inserted: false, + fieldMeta: { type: 'signature' }, + }, + }); + + const distributeEnvelopeRequest = await request.post(`${baseUrl}/envelope/distribute`, { + headers: { Authorization: `Bearer ${token}` }, + data: { + envelopeId: envelope.id, + } satisfies TDistributeEnvelopeRequest, + }); + + if (!distributeEnvelopeRequest.ok()) { + const errorBody = await distributeEnvelopeRequest.json(); + throw new Error(`Failed to distribute envelope: ${JSON.stringify(errorBody)}`); + } + + const updatedRecipient = await prisma.recipient.findUniqueOrThrow({ + where: { id: recipientRecord.id }, + }); + + const signUrl = `/sign/${updatedRecipient.token}`; + + // Mark signature fields as inserted via Prisma BEFORE loading page + // (bypasses Konva canvas interaction) + await markSignatureFieldsAsInserted(envelope.id, recipientRecord.id); + + await apiSignin({ + page, + email: recipient.email, + redirectPath: signUrl, + }); + + await completeSigningFlow(page); + + await expect(async () => { + const { status } = await prisma.envelope.findFirstOrThrow({ + where: { id: envelope.id }, + }); + expect(status).toBe(DocumentStatus.COMPLETED); + }).toPass({ timeout: 10000 }); + + await verifyOriginalData(envelope.id, true); + }); + + test('[FILE_CONVERSION_UI]: should upload, convert, and sign a DOCX document', async ({ + page, + request, + }) => { + const { user: uploader, team } = await seedUser(); + const { user: recipient } = await seedUser(); + + const { token } = await createApiToken({ + userId: uploader.id, + teamId: team.id, + tokenName: 'test', + expiresIn: null, + }); + + await apiSignin({ + page, + email: uploader.email, + redirectPath: `/t/${team.url}/documents`, + }); + + await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible(); + + // Use .first() because there are two upload inputs (dropzone wrapper + upload button) + const fileInput = page.getByTestId('document-upload-input').first(); + await fileInput.setInputFiles(path.join(__dirname, '../../../../assets/test-document.docx')); + + try { + await page.waitForURL(/\/documents\/.*\/edit/, { timeout: 15000 }); + } catch { + const errorToast = page.locator('[data-sonner-toast][data-type="error"]'); + if (await errorToast.isVisible()) { + test.skip(true, 'Gotenberg conversion service is not available'); + return; + } + throw new Error('Upload failed without expected error toast'); + } + + const urlMatch = page.url().match(/documents\/(.+?)\/edit/); + const envelopeId = urlMatch?.[1]; + expect(envelopeId).toBeDefined(); + + const envelope = await prisma.envelope.findUniqueOrThrow({ + where: { id: envelopeId }, + include: { envelopeItems: true }, + }); + + const recipientRecord = await prisma.recipient.create({ + data: { + email: recipient.email, + name: recipient.name || '', + role: RecipientRole.SIGNER, + envelopeId: envelope.id, + token: nanoid(), + }, + }); + + await prisma.field.create({ + data: { + envelopeId: envelope.id, + recipientId: recipientRecord.id, + type: FieldType.SIGNATURE, + page: 1, + positionX: 10, + positionY: 10, + width: 20, + height: 5, + envelopeItemId: envelope.envelopeItems[0].id, + customText: '', + inserted: false, + fieldMeta: { type: 'signature' }, + }, + }); + + const distributeEnvelopeRequest = await request.post(`${baseUrl}/envelope/distribute`, { + headers: { Authorization: `Bearer ${token}` }, + data: { + envelopeId: envelope.id, + } satisfies TDistributeEnvelopeRequest, + }); + + if (!distributeEnvelopeRequest.ok()) { + const errorBody = await distributeEnvelopeRequest.json(); + throw new Error(`Failed to distribute envelope: ${JSON.stringify(errorBody)}`); + } + + const updatedRecipient = await prisma.recipient.findUniqueOrThrow({ + where: { id: recipientRecord.id }, + }); + + const signUrl = `/sign/${updatedRecipient.token}`; + + // Mark signature fields as inserted via Prisma BEFORE loading page + // (bypasses Konva canvas interaction) + await markSignatureFieldsAsInserted(envelope.id, recipientRecord.id); + + await apiSignin({ + page, + email: recipient.email, + redirectPath: signUrl, + }); + + await completeSigningFlow(page); + + await expect(async () => { + const { status } = await prisma.envelope.findFirstOrThrow({ + where: { id: envelope.id }, + }); + expect(status).toBe(DocumentStatus.COMPLETED); + }).toPass({ timeout: 10000 }); + + await verifyOriginalData(envelope.id, true); + }); + + test('[FILE_CONVERSION_UI]: should upload and sign a PDF document (no conversion)', async ({ + page, + request, + }) => { + const { user: uploader, team } = await seedUser(); + const { user: recipient } = await seedUser(); + + const { token } = await createApiToken({ + userId: uploader.id, + teamId: team.id, + tokenName: 'test', + expiresIn: null, + }); + + await apiSignin({ + page, + email: uploader.email, + redirectPath: `/t/${team.url}/documents`, + }); + + await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible(); + + // Use .first() because there are two upload inputs (dropzone wrapper + upload button) + const fileInput = page.getByTestId('document-upload-input').first(); + await fileInput.setInputFiles(path.join(__dirname, '../../../../assets/example.pdf')); + + await page.waitForURL(/\/documents\/.*\/edit/, { timeout: 15000 }); + + const urlMatch = page.url().match(/documents\/(.+?)\/edit/); + const envelopeId = urlMatch?.[1]; + expect(envelopeId).toBeDefined(); + + const envelope = await prisma.envelope.findUniqueOrThrow({ + where: { id: envelopeId }, + include: { envelopeItems: true }, + }); + + const recipientRecord = await prisma.recipient.create({ + data: { + email: recipient.email, + name: recipient.name || '', + role: RecipientRole.SIGNER, + envelopeId: envelope.id, + token: nanoid(), + }, + }); + + await prisma.field.create({ + data: { + envelopeId: envelope.id, + recipientId: recipientRecord.id, + type: FieldType.SIGNATURE, + page: 1, + positionX: 10, + positionY: 10, + width: 20, + height: 5, + envelopeItemId: envelope.envelopeItems[0].id, + customText: '', + inserted: false, + fieldMeta: { type: 'signature' }, + }, + }); + + const distributeEnvelopeRequest = await request.post(`${baseUrl}/envelope/distribute`, { + headers: { Authorization: `Bearer ${token}` }, + data: { + envelopeId: envelope.id, + } satisfies TDistributeEnvelopeRequest, + }); + + if (!distributeEnvelopeRequest.ok()) { + const errorBody = await distributeEnvelopeRequest.json(); + throw new Error(`Failed to distribute envelope: ${JSON.stringify(errorBody)}`); + } + + const updatedRecipient = await prisma.recipient.findUniqueOrThrow({ + where: { id: recipientRecord.id }, + }); + + const signUrl = `/sign/${updatedRecipient.token}`; + + // Mark signature fields as inserted via Prisma BEFORE loading page + // (bypasses Konva canvas interaction) + await markSignatureFieldsAsInserted(envelope.id, recipientRecord.id); + + await apiSignin({ + page, + email: recipient.email, + redirectPath: signUrl, + }); + + await completeSigningFlow(page); + + await expect(async () => { + const { status } = await prisma.envelope.findFirstOrThrow({ + where: { id: envelope.id }, + }); + expect(status).toBe(DocumentStatus.COMPLETED); + }).toPass({ timeout: 10000 }); + + // PDF should NOT have originalData + await verifyOriginalData(envelope.id, false); + }); + + test('[FILE_CONVERSION_UI]: should show error when Gotenberg is unavailable', async ({ + page, + }) => { + const { user, team } = await seedUser(); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents`, + }); + + await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible(); + + // Use .first() because there are two upload inputs (dropzone wrapper + upload button) + const fileInput = page.getByTestId('document-upload-input').first(); + await fileInput.setInputFiles(path.join(__dirname, '../../../../assets/test-image.png')); + + // Wait for either navigation (success) or error toast (Gotenberg down) + const navigationPromise = page.waitForURL(/\/documents\/.*\/edit/, { timeout: 15000 }); + const toastPromise = page.waitForSelector('[data-sonner-toast][data-type="error"]', { + timeout: 15000, + }); + + const result = await Promise.race([ + navigationPromise.then(() => 'navigation'), + toastPromise.then(() => 'toast'), + ]).catch(() => 'timeout'); + + if (result === 'navigation') { + test.skip(true, 'Gotenberg is available - skipping error scenario test'); + return; + } + + if (result === 'toast') { + // Error toast appeared - Gotenberg is unavailable + const errorToast = page.locator('[data-sonner-toast][data-type="error"]'); + await expect(errorToast).toBeVisible(); + } else { + throw new Error('Neither navigation nor error toast occurred'); + } + }); + }); +}); diff --git a/packages/lib/client-only/download-pdf.ts b/packages/lib/client-only/download-pdf.ts index 735d2765c..590d2fd5d 100644 --- a/packages/lib/client-only/download-pdf.ts +++ b/packages/lib/client-only/download-pdf.ts @@ -13,11 +13,32 @@ type DownloadPDFProps = { /** * Specifies which version of the document to download. * 'signed': Downloads the signed version (default). - * 'original': Downloads the original version. + * 'original': Downloads the original version (may be DOCX, PNG, JPEG if converted). */ version?: DocumentVersion; }; +const getFilenameFromContentDisposition = (header: string | null): string | null => { + if (!header) return null; + + const filenameStarMatch = header.match(/filename\*=(?:UTF-8''|utf-8'')([^;]+)/i); + if (filenameStarMatch) { + return decodeURIComponent(filenameStarMatch[1]); + } + + const filenameMatch = header.match(/filename="([^"]+)"/); + if (filenameMatch) { + return filenameMatch[1]; + } + + const filenameNoQuotesMatch = header.match(/filename=([^;\s]+)/); + if (filenameNoQuotesMatch) { + return filenameNoQuotesMatch[1]; + } + + return null; +}; + export const downloadPDF = async ({ envelopeItem, token, @@ -31,13 +52,23 @@ export const downloadPDF = async ({ version, }); - const blob = await fetch(downloadUrl).then(async (res) => await res.blob()); + const response = await fetch(downloadUrl); + const blob = await response.blob(); - const baseTitle = (fileName ?? 'document').replace(/\.pdf$/, ''); - const suffix = version === 'signed' ? '_signed.pdf' : '.pdf'; + const contentDisposition = response.headers.get('Content-Disposition'); + const serverFilename = getFilenameFromContentDisposition(contentDisposition); + + let filename: string; + if (serverFilename) { + filename = serverFilename; + } else { + const baseTitle = (fileName ?? 'document').replace(/\.[^/.]+$/, ''); + const suffix = version === 'signed' ? '_signed.pdf' : '.pdf'; + filename = `${baseTitle}${suffix}`; + } downloadFile({ - filename: `${baseTitle}${suffix}`, + filename, data: blob, }); }; diff --git a/packages/lib/constants/upload.ts b/packages/lib/constants/upload.ts index 50a0e5ab5..ec1716155 100644 --- a/packages/lib/constants/upload.ts +++ b/packages/lib/constants/upload.ts @@ -11,3 +11,13 @@ export const isAllowedMimeType = (mimeType: string): boolean => mimeType in ALLOWED_UPLOAD_MIME_TYPES; export const getGotenbergUrl = (): string | undefined => env('NEXT_PRIVATE_GOTENBERG_URL'); + +export const getGotenbergTimeout = (): number => { + const timeout = env('NEXT_PRIVATE_GOTENBERG_TIMEOUT'); + return timeout ? parseInt(timeout, 10) : 30_000; +}; + +export const getFileExtensionForMimeType = (mimeType: string): string => { + const extensions = ALLOWED_UPLOAD_MIME_TYPES[mimeType]; + return extensions?.[0] ?? '.pdf'; +}; diff --git a/packages/lib/server-only/document-data/create-document-data.ts b/packages/lib/server-only/document-data/create-document-data.ts index 1947ceaf2..a900e38cf 100644 --- a/packages/lib/server-only/document-data/create-document-data.ts +++ b/packages/lib/server-only/document-data/create-document-data.ts @@ -5,12 +5,14 @@ import { prisma } from '@documenso/prisma'; export type CreateDocumentDataOptions = { type: DocumentDataType; data: string; + originalData?: string; originalMimeType?: string; }; export const createDocumentData = async ({ type, data, + originalData, originalMimeType, }: CreateDocumentDataOptions) => { return await prisma.documentData.create({ @@ -18,6 +20,7 @@ export const createDocumentData = async ({ type, data, initialData: data, + originalData, originalMimeType, }, }); diff --git a/packages/lib/server-only/file-conversion/convert-to-pdf.ts b/packages/lib/server-only/file-conversion/convert-to-pdf.ts index 303d4d22d..4327c2f3f 100644 --- a/packages/lib/server-only/file-conversion/convert-to-pdf.ts +++ b/packages/lib/server-only/file-conversion/convert-to-pdf.ts @@ -10,6 +10,7 @@ type FileInput = { export type ConvertToPdfResult = { pdfBuffer: Buffer; + originalBuffer: Buffer | null; originalMimeType: string; }; @@ -24,17 +25,17 @@ export const convertToPdfIfNeeded = async (file: FileInput): Promise controller.abort(), CONVERSION_TIMEOUT_MS); + const timeoutId = setTimeout(() => controller.abort(), getGotenbergTimeout()); try { const response = await fetch(`${gotenbergUrl}/forms/libreoffice/convert`, { diff --git a/packages/lib/universal/upload/put-file.server.ts b/packages/lib/universal/upload/put-file.server.ts index e67009ad7..b2a103c62 100644 --- a/packages/lib/universal/upload/put-file.server.ts +++ b/packages/lib/universal/upload/put-file.server.ts @@ -46,6 +46,7 @@ export const putPdfFileServerSide = async (file: File) => { type PutNormalizedPdfOptions = { file: File; + originalData?: string; originalMimeType?: string; }; @@ -54,6 +55,7 @@ type PutNormalizedPdfOptions = { */ export const putNormalizedPdfFileServerSide = async ({ file, + originalData, originalMimeType, }: PutNormalizedPdfOptions) => { const buffer = Buffer.from(await file.arrayBuffer()); @@ -71,6 +73,7 @@ export const putNormalizedPdfFileServerSide = async ({ return await createDocumentData({ type: documentData.type, data: documentData.data, + originalData, originalMimeType, }); }; diff --git a/packages/prisma/migrations/20251229215756_add_original_data_field/migration.sql b/packages/prisma/migrations/20251229215756_add_original_data_field/migration.sql new file mode 100644 index 000000000..efdd1f115 --- /dev/null +++ b/packages/prisma/migrations/20251229215756_add_original_data_field/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "DocumentData" ADD COLUMN "originalData" TEXT; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 25d23b30a..e06856219 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -490,6 +490,7 @@ model DocumentData { type DocumentDataType data String initialData String + originalData String? originalMimeType String? envelopeItem EnvelopeItem? } diff --git a/packages/trpc/server/document-router/create-document.ts b/packages/trpc/server/document-router/create-document.ts index 8df992154..a0953c4ff 100644 --- a/packages/trpc/server/document-router/create-document.ts +++ b/packages/trpc/server/document-router/create-document.ts @@ -3,8 +3,12 @@ import { EnvelopeType } from '@prisma/client'; import { getServerLimits } from '@documenso/ee/server-only/limits/server'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope'; +import { convertToPdfIfNeeded } from '@documenso/lib/server-only/file-conversion/convert-to-pdf'; import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf'; -import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server'; +import { + putFileServerSide, + putNormalizedPdfFileServerSide, +} from '@documenso/lib/universal/upload/put-file.server'; import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; import { authenticatedProcedure } from '../trpc'; @@ -36,7 +40,9 @@ export const createDocumentRoute = authenticatedProcedure attachments, } = payload; - let pdf = Buffer.from(await file.arrayBuffer()); + const { pdfBuffer, originalBuffer, originalMimeType } = await convertToPdfIfNeeded(file); + + let pdf = pdfBuffer; if (formValues) { // eslint-disable-next-line require-atomic-updates @@ -46,12 +52,24 @@ export const createDocumentRoute = authenticatedProcedure }); } + let originalData: string | undefined; + if (originalBuffer) { + const stored = await putFileServerSide({ + name: `original-${file.name}`, + type: originalMimeType, + arrayBuffer: async () => Promise.resolve(originalBuffer), + }); + originalData = stored.data; + } + const { id: documentDataId } = await putNormalizedPdfFileServerSide({ file: { name: file.name, type: 'application/pdf', arrayBuffer: async () => Promise.resolve(pdf), }, + originalData, + originalMimeType, }); ctx.logger.info({ diff --git a/packages/trpc/server/envelope-router/create-envelope.ts b/packages/trpc/server/envelope-router/create-envelope.ts index 44f298880..55697fa63 100644 --- a/packages/trpc/server/envelope-router/create-envelope.ts +++ b/packages/trpc/server/envelope-router/create-envelope.ts @@ -2,7 +2,10 @@ import { getServerLimits } from '@documenso/ee/server-only/limits/server'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope'; import { convertToPdfIfNeeded } from '@documenso/lib/server-only/file-conversion/convert-to-pdf'; -import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server'; +import { + putFileServerSide, + putNormalizedPdfFileServerSide, +} from '@documenso/lib/universal/upload/put-file.server'; import { insertFormValuesInPdf } from '../../../lib/server-only/pdf/insert-form-values-in-pdf'; import { authenticatedProcedure } from '../trpc'; @@ -61,11 +64,9 @@ export const createEnvelopeRoute = authenticatedProcedure }); } - // For each file, convert to PDF if needed, then store. const envelopeItems = await Promise.all( files.map(async (file) => { - // Convert to PDF if needed (DOCX, images, etc) - const { pdfBuffer, originalMimeType } = await convertToPdfIfNeeded(file); + const { pdfBuffer, originalBuffer, originalMimeType } = await convertToPdfIfNeeded(file); let pdf = pdfBuffer; @@ -77,12 +78,23 @@ export const createEnvelopeRoute = authenticatedProcedure }); } + let originalData: string | undefined; + if (originalBuffer) { + const stored = await putFileServerSide({ + name: `original-${file.name}`, + type: originalMimeType, + arrayBuffer: async () => Promise.resolve(originalBuffer), + }); + originalData = stored.data; + } + const { id: documentDataId } = await putNormalizedPdfFileServerSide({ file: { name: file.name, type: 'application/pdf', arrayBuffer: async () => Promise.resolve(pdf), }, + originalData, originalMimeType, });