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
@@ -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');
});
});