feat: add pending signed pdf downloads for recipients

Recipients can download a PDF that contains the inserted fields while
the envelope is in the PENDING state.

The token file route accepts the `pending` version. The PDF contains
only the fields from recipients who signed and the fields of the
recipient who makes the request. This is the same set of fields that
the signing page shows to the recipient.

The signing page and the completion page show the download dialog for
pending envelopes. The dialog does not show the "Partial" button for
legacy envelopes.
This commit is contained in:
ephraimduncan
2026-08-11 15:52:39 +00:00
parent 962cffc9f5
commit e6ad94a58f
9 changed files with 227 additions and 80 deletions
@@ -29,9 +29,9 @@ type EnvelopeDownloadDialogProps = {
* button is hidden for them.
*
* Optional: omit it on call sites where the status can never be PENDING (DRAFT,
* COMPLETED, REJECTED) or when a recipient token is set, since the Partial button
* is also gated on those. Pass it from team-side call sites that can render the
* dialog for a PENDING envelope.
* COMPLETED, REJECTED) or that only render v2 envelopes, such as the v2 signing
* page. Pass it from call sites that can render the dialog for a PENDING
* envelope of either version.
*/
isLegacy?: boolean;
envelopeItems?: EnvelopeItemToDownload[];
@@ -67,12 +67,10 @@ export const EnvelopeDownloadDialog = ({
// The dialog shows the original document alongside one of:
// - "Signed" (when the envelope is COMPLETED)
// - "Partial" (when the envelope is PENDING, not legacy, and we are on the
// team/owner side; recipients are intentionally not offered this since the
// partial PDF carries no PKI signature and would create a leak vector for
// half-executed contracts; legacy envelopes use a different rendering
// pipeline that the partial-download helper does not implement)
// - nothing (DRAFT, REJECTED, PENDING with recipient token, or legacy PENDING)
// - "Partial" (when the envelope is PENDING and not legacy; legacy envelopes
// use a different rendering pipeline that the partial-download helper does
// not implement)
// - nothing (DRAFT, REJECTED, or legacy PENDING)
const secondaryDownload = useMemo<{ version: 'signed' | 'pending'; label: string } | null>(() => {
if (envelopeStatus === DocumentStatus.COMPLETED) {
return {
@@ -81,7 +79,7 @@ export const EnvelopeDownloadDialog = ({
};
}
if (envelopeStatus === DocumentStatus.PENDING && !token && !isLegacy) {
if (envelopeStatus === DocumentStatus.PENDING && !isLegacy) {
return {
version: 'pending',
label: t({ message: 'Partial', context: 'Partially signed document (adjective)' }),
@@ -89,7 +87,7 @@ export const EnvelopeDownloadDialog = ({
}
return null;
}, [envelopeStatus, isLegacy, token, t]);
}, [envelopeStatus, isLegacy, t]);
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getManyByToken.useQuery(
{
@@ -148,6 +148,13 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
);
}
// The envelope may complete or get rejected while this page polls, so derive
// the download dialog status from the live signing status.
const envelopeStatus = match(signingStatus)
.with('COMPLETED', () => DocumentStatus.COMPLETED)
.with('REJECTED', () => DocumentStatus.REJECTED)
.otherwise(() => document.status);
return (
<>
<RecipientBranding branding={branding} cspNonce={cspNonce} />
@@ -255,10 +262,11 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
className="w-full max-w-none md:flex-1"
/>
{isDocumentCompleted(document) && (
{(isDocumentCompleted(envelopeStatus) || envelopeStatus === DocumentStatus.PENDING) && (
<EnvelopeDownloadDialog
envelopeId={document.envelopeId}
envelopeStatus={document.status}
envelopeStatus={envelopeStatus}
isLegacy={document.internalVersion === 1}
envelopeItems={document.envelopeItems}
token={recipient?.token}
trigger={
+1 -10
View File
@@ -83,16 +83,7 @@ export const downloadRoute = new Hono<HonoEnv>()
},
},
include: {
envelope: {
include: {
recipients: {
select: {
role: true,
signingStatus: true,
},
},
},
},
envelope: true,
documentData: true,
},
});
+17 -7
View File
@@ -11,8 +11,7 @@ import {
DocumentStatus,
type EnvelopeType,
EnvelopeType as EnvelopeTypeEnum,
type RecipientRole,
type SigningStatus,
SigningStatus,
type TemplateType,
TemplateType as TemplateTypeEnum,
} from '@prisma/client';
@@ -55,17 +54,13 @@ type EnvelopeForPendingDownload = {
id: string;
status: DocumentStatus;
internalVersion: number;
recipients: Array<{
role: RecipientRole;
signingStatus: SigningStatus;
}>;
};
/**
* Options shape varies by `version`:
* - `signed` / `original`: serves stored bytes; only needs envelope `status` for cache headers.
* - `pending`: generates a fresh PDF with currently-inserted fields burned in; needs the
* full envelope (id, status, internalVersion, recipients) plus envelopeItemId to query fields.
* envelope (id, status, internalVersion) plus envelopeItemId to query fields.
*/
type HandleEnvelopeItemFileRequestOptions = {
title: string;
@@ -81,6 +76,13 @@ type HandleEnvelopeItemFileRequestOptions = {
version: 'pending';
envelopeItemId: string;
envelope: EnvelopeForPendingDownload;
/**
* When set, only fields from recipients who have signed, plus the fields of
* the recipient owning this token, are burned in. Keeps recipient downloads
* in parity with what the signing page shows them.
*/
recipientToken?: string;
}
);
@@ -165,6 +167,7 @@ const handlePendingFileRequest = async ({
envelopeItemId,
envelope,
documentData,
recipientToken,
context: c,
}: PendingFileRequestOptions) => {
if (envelope.status !== DocumentStatus.PENDING) {
@@ -191,6 +194,13 @@ const handlePendingFileRequest = async ({
where: {
envelopeItemId,
inserted: true,
...(recipientToken
? {
recipient: {
OR: [{ signingStatus: SigningStatus.SIGNED }, { token: recipientToken }],
},
}
: {}),
},
include: {
signature: true,
+67 -45
View File
@@ -160,12 +160,6 @@ export const filesRoute = new Hono<HonoEnv>()
documentData: true,
},
},
recipients: {
select: {
role: true,
signingStatus: true,
},
},
},
});
@@ -289,52 +283,80 @@ export const filesRoute = new Hono<HonoEnv>()
'/token/:token/envelopeItem/:envelopeItemId/download/:version?',
sValidator('param', ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema),
async (c) => {
const { token, envelopeItemId, version } = c.req.valid('param');
const logger = c.get('logger');
let envelopeWhereQuery: Prisma.EnvelopeItemWhereUniqueInput = {
id: envelopeItemId,
envelope: {
recipients: {
some: {
token,
},
},
},
};
try {
const { token, envelopeItemId, version } = c.req.valid('param');
if (token.startsWith('qr_')) {
envelopeWhereQuery = {
let envelopeWhereQuery: Prisma.EnvelopeItemWhereUniqueInput = {
id: envelopeItemId,
envelope: {
qrToken: token,
recipients: {
some: {
token,
},
},
},
};
if (token.startsWith('qr_')) {
envelopeWhereQuery = {
id: envelopeItemId,
envelope: {
qrToken: token,
},
};
}
const envelopeItem = await prisma.envelopeItem.findUnique({
where: envelopeWhereQuery,
include: {
envelope: true,
documentData: true,
},
});
if (!envelopeItem) {
return c.json({ error: 'Envelope item not found' }, 404);
}
if (!envelopeItem.documentData) {
return c.json({ error: 'Document data not found' }, 404);
}
const baseOptions = {
title: envelopeItem.title,
documentData: envelopeItem.documentData,
isDownload: true,
context: c,
} as const;
if (version === 'pending') {
return await handleEnvelopeItemFileRequest({
...baseOptions,
version,
envelopeItemId: envelopeItem.id,
envelope: envelopeItem.envelope,
recipientToken: token,
});
}
return await handleEnvelopeItemFileRequest({
...baseOptions,
version,
status: envelopeItem.envelope.status,
});
} catch (error) {
logger.error(error);
if (error instanceof AppError) {
const { status, body } = AppError.toRestAPIError(error);
return c.json({ error: body.message, code: error.code }, status);
}
return c.json({ error: 'Internal server error' }, 500);
}
const envelopeItem = await prisma.envelopeItem.findUnique({
where: envelopeWhereQuery,
include: {
envelope: true,
documentData: true,
},
});
if (!envelopeItem) {
return c.json({ error: 'Envelope item not found' }, 404);
}
if (!envelopeItem.documentData) {
return c.json({ error: 'Document data not found' }, 404);
}
return await handleEnvelopeItemFileRequest({
title: envelopeItem.title,
status: envelopeItem.envelope.status,
documentData: envelopeItem.documentData,
version,
isDownload: true,
context: c,
});
},
);
+1 -1
View File
@@ -44,7 +44,7 @@ export type TGetEnvelopeItemFileDownloadRequestParams = z.infer<typeof ZGetEnvel
export const ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema = z.object({
token: z.string().min(1),
envelopeItemId: z.string().min(1),
version: z.enum(['signed', 'original']).default('signed'),
version: z.enum(['signed', 'original', 'pending']).default('signed'),
});
export type TGetEnvelopeItemFileTokenDownloadRequestParams = z.infer<
@@ -241,4 +241,123 @@ test.describe('API V2 partial signed PDF downloads', () => {
expect(legacyResponse.status()).toBe(400);
expect(legacyError.code).toBe('ENVELOPE_LEGACY');
});
test('allows recipients to download the partial PDF via their token', async ({ request }) => {
const { envelope, distributeResult } = await apiSeedPendingDocument(request, {
recipients: [
{ email: 'partial-token-1@test.documenso.com', name: 'Partial Token 1' },
{ email: 'partial-token-2@test.documenso.com', name: 'Partial Token 2' },
],
fieldsPerRecipient: [
[{ type: FieldType.SIGNATURE, page: 1, positionX: 5, positionY: 5, width: 15, height: 5 }],
[
{
type: FieldType.SIGNATURE,
page: 1,
positionX: 5,
positionY: 15,
width: 15,
height: 5,
},
],
],
});
const [recipientOne, recipientTwo] = distributeResult.recipients;
const documentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
const envelopeItem = envelope.envelopeItems[0];
const recipientOneField = envelope.fields.find((field) => field.recipientId === recipientOne.id);
if (!recipientOneField) {
throw new Error('Expected signature field not found');
}
const tokenDownloadUrl = (token: string) =>
`${WEBAPP_BASE_URL}/api/files/token/${token}/envelopeItem/${envelopeItem.id}/download/pending`;
// Recipient one inserts their field without completing the document.
await trpcMutation(request, 'envelope.field.sign', {
token: recipientOne.token,
fieldId: recipientOneField.id,
fieldValue: {
type: FieldType.SIGNATURE,
value: 'Signature',
},
});
const recipientOneResponse = await request.get(tokenDownloadUrl(recipientOne.token));
expect(recipientOneResponse.status()).toBe(200);
expect(recipientOneResponse.headers()['content-type']).toContain('application/pdf');
expect(recipientOneResponse.headers()['cache-control']).toBe('no-store, private');
expect(recipientOneResponse.headers()['content-disposition']).toContain('_pending.pdf');
await getPdfBytes(recipientOneResponse);
// Recipient two must not see recipient one's in-progress field. The ETag is
// derived from the included fields, so the two downloads must differ.
const recipientTwoResponse = await request.get(tokenDownloadUrl(recipientTwo.token));
expect(recipientTwoResponse.status()).toBe(200);
await getPdfBytes(recipientTwoResponse);
expect(recipientOneResponse.headers().etag).not.toBe(recipientTwoResponse.headers().etag);
// Once recipient one completes, their field becomes visible to recipient two.
await trpcMutation(request, 'recipient.completeDocumentWithToken', {
token: recipientOne.token,
documentId,
});
await expect(async () => {
const dbRecipient = await prisma.recipient.findFirstOrThrow({
where: {
id: recipientOne.id,
},
});
expect(dbRecipient.signingStatus).toBe(SigningStatus.SIGNED);
}).toPass();
const afterCompletionResponse = await request.get(tokenDownloadUrl(recipientTwo.token));
expect(afterCompletionResponse.status()).toBe(200);
expect(afterCompletionResponse.headers().etag).toBe(recipientOneResponse.headers().etag);
});
test('rejects a recipient token pending download once the envelope is completed', async ({ request }) => {
const { envelope, distributeResult } = await apiSeedPendingDocument(request);
const [recipient] = distributeResult.recipients;
const documentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
const recipientField = envelope.fields.find((field) => field.recipientId === recipient.id);
if (!recipientField) {
throw new Error('Expected signature field not found');
}
await signAndCompleteRecipient({
request,
token: recipient.token,
documentId,
fieldId: recipientField.id,
});
await expect(async () => {
const dbEnvelope = await prisma.envelope.findUniqueOrThrow({
where: {
id: envelope.id,
},
});
expect(dbEnvelope.status).toBe(DocumentStatus.COMPLETED);
}).toPass({ timeout: 15_000 });
const completedResponse = await request.get(
`${WEBAPP_BASE_URL}/api/files/token/${recipient.token}/envelopeItem/${envelope.envelopeItems[0].id}/download/pending`,
);
const completedError = await completedResponse.json();
expect(completedResponse.status()).toBe(400);
expect(completedError.code).toBe('ENVELOPE_COMPLETED');
});
});
+1 -2
View File
@@ -15,8 +15,7 @@ type DownloadPDFProps = {
* 'signed': Downloads the signed version (default).
* 'original': Downloads the original version.
* 'pending': Downloads the original document with currently-inserted fields burned in.
* Only valid while the envelope is in PENDING status. Not supported via
* recipient token.
* Only valid while the envelope is in PENDING status.
*/
version?: DocumentVersion;
};
+2 -2
View File
@@ -4,8 +4,8 @@ import type { EnvelopeItem } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
/**
* `pending` is only supported when there is no recipient token (team/owner-side downloads
* via the session-authed file route). The recipient-token route does not accept `pending`.
* `pending` downloads a PDF with the currently-inserted fields burned in. Supported
* for both session-authed and recipient-token downloads while the envelope is PENDING.
*/
export type EnvelopeItemPdfUrlOptions =
| {