feat: download document via api v2 (#1918)

adds document download functionality to the API v2, returning
pre-signed S3 URLs that provide secure, time-limited access to document
files similar to what happens in the API v1 download document endpoint.
This commit is contained in:
Ephraim Duncan
2025-08-05 02:29:21 +00:00
committed by GitHub
parent 5689cd1538
commit 9b01a2318f
5 changed files with 123 additions and 18 deletions

View File

@ -0,0 +1,93 @@
import { DocumentDataType } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getPresignGetUrl } from '@documenso/lib/universal/upload/server-actions';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { authenticatedProcedure } from '../trpc';
import { ZDownloadDocumentRequestSchema, ZDownloadDocumentResponseSchema } from './schema';
export const downloadDocumentRoute = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/document/{documentId}/download-beta',
summary: 'Download document (beta)',
description: 'Get a pre-signed download URL for the original or signed version of a document',
tags: ['Document'],
},
})
.input(ZDownloadDocumentRequestSchema)
.output(ZDownloadDocumentResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { documentId, version } = input;
ctx.logger.info({
input: {
documentId,
version,
},
});
if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document downloads are only available when S3 storage is configured.',
});
}
const document = await getDocumentById({
documentId,
userId: user.id,
teamId,
});
if (!document.documentData) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document data not found',
});
}
if (document.documentData.type !== DocumentDataType.S3_PATH) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document is not stored in S3 and cannot be downloaded via URL.',
});
}
if (version === 'signed' && !isDocumentCompleted(document.status)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document is not completed yet.',
});
}
try {
const documentData =
version === 'original'
? document.documentData.initialData || document.documentData.data
: document.documentData.data;
const { url } = await getPresignGetUrl(documentData);
const baseTitle = document.title.replace(/\.pdf$/, '');
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
const filename = `${baseTitle}${suffix}`;
return {
downloadUrl: url,
filename,
contentType: 'application/pdf',
};
} catch (error) {
ctx.logger.error({
error,
message: 'Failed to generate download URL',
documentId,
version,
});
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Failed to generate download URL',
});
}
});

View File

@ -1,5 +1,4 @@
import { DocumentDataType } from '@prisma/client';
import { TRPCError } from '@trpc/server';
import { DateTime } from 'luxon';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
@ -27,6 +26,7 @@ import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-action
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { authenticatedProcedure, procedure, router } from '../trpc';
import { downloadDocumentRoute } from './download-document';
import { findInboxRoute } from './find-inbox';
import { getInboxCountRoute } from './get-inbox-count';
import {
@ -63,6 +63,7 @@ export const documentRouter = router({
getCount: getInboxCountRoute,
},
updateDocument: updateDocumentRoute,
downloadDocument: downloadDocumentRoute,
/**
* @private
@ -636,8 +637,7 @@ export const documentRouter = router({
}).catch(() => null);
if (!document || (teamId && document.teamId !== teamId)) {
throw new TRPCError({
code: 'FORBIDDEN',
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have access to this document.',
});
}

View File

@ -346,3 +346,22 @@ export const ZDownloadAuditLogsMutationSchema = z.object({
export const ZDownloadCertificateMutationSchema = z.object({
documentId: z.number(),
});
export const ZDownloadDocumentRequestSchema = z.object({
documentId: z.number().describe('The ID of the document to download.'),
version: z
.enum(['original', 'signed'])
.describe(
'The version of the document to download. "signed" returns the completed document with signatures, "original" returns the original uploaded document.',
)
.default('signed'),
});
export const ZDownloadDocumentResponseSchema = z.object({
downloadUrl: z.string().describe('Pre-signed URL for downloading the PDF file'),
filename: z.string().describe('The filename of the PDF file'),
contentType: z.string().describe('MIME type of the file'),
});
export type TDownloadDocumentRequest = z.infer<typeof ZDownloadDocumentRequestSchema>;
export type TDownloadDocumentResponse = z.infer<typeof ZDownloadDocumentResponseSchema>;

View File

@ -1,5 +1,4 @@
import { TRPCError } from '@trpc/server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createFolder } from '@documenso/lib/server-only/folder/create-folder';
import { deleteFolder } from '@documenso/lib/server-only/folder/delete-folder';
import { findFolders } from '@documenso/lib/server-only/folder/find-folders';
@ -137,8 +136,7 @@ export const folderRouter = router({
type,
});
} catch (error) {
throw new TRPCError({
code: 'NOT_FOUND',
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Parent folder not found',
});
}
@ -248,8 +246,7 @@ export const folderRouter = router({
type: currentFolder.type,
});
} catch (error) {
throw new TRPCError({
code: 'NOT_FOUND',
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Parent folder not found',
});
}
@ -294,8 +291,7 @@ export const folderRouter = router({
type: FolderType.DOCUMENT,
});
} catch (error) {
throw new TRPCError({
code: 'NOT_FOUND',
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
@ -340,8 +336,7 @@ export const folderRouter = router({
type: FolderType.TEMPLATE,
});
} catch (error) {
throw new TRPCError({
code: 'NOT_FOUND',
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}

View File

@ -1,6 +1,5 @@
import type { Document } from '@prisma/client';
import { DocumentDataType } from '@prisma/client';
import { TRPCError } from '@trpc/server';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
@ -556,9 +555,9 @@ export const templateRouter = router({
});
if (csv.length > 4 * 1024 * 1024) {
throw new TRPCError({
code: 'BAD_REQUEST',
throw new AppError(AppErrorCode.LIMIT_EXCEEDED, {
message: 'File size exceeds 4MB limit',
statusCode: 400,
});
}
@ -569,8 +568,7 @@ export const templateRouter = router({
});
if (!template) {
throw new TRPCError({
code: 'NOT_FOUND',
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
}