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