diff --git a/apps/remix/app/components/dialogs/envelope-download-dialog.tsx b/apps/remix/app/components/dialogs/envelope-download-dialog.tsx index e7ffb7ea5..7fdedfd2b 100644 --- a/apps/remix/app/components/dialogs/envelope-download-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelope-download-dialog.tsx @@ -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( { diff --git a/apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx b/apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx index 881d75238..b87368d19 100644 --- a/apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx +++ b/apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx @@ -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 ( <> @@ -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) && ( () }, }, include: { - envelope: { - include: { - recipients: { - select: { - role: true, - signingStatus: true, - }, - }, - }, - }, + envelope: true, documentData: true, }, }); diff --git a/apps/remix/server/api/files/files.helpers.ts b/apps/remix/server/api/files/files.helpers.ts index 3b0dc1f05..ddce3653d 100644 --- a/apps/remix/server/api/files/files.helpers.ts +++ b/apps/remix/server/api/files/files.helpers.ts @@ -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, diff --git a/apps/remix/server/api/files/files.ts b/apps/remix/server/api/files/files.ts index bbca38885..7676637a7 100644 --- a/apps/remix/server/api/files/files.ts +++ b/apps/remix/server/api/files/files.ts @@ -160,12 +160,6 @@ export const filesRoute = new Hono() documentData: true, }, }, - recipients: { - select: { - role: true, - signingStatus: true, - }, - }, }, }); @@ -289,52 +283,80 @@ export const filesRoute = new Hono() '/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, - }); }, ); diff --git a/apps/remix/server/api/files/files.types.ts b/apps/remix/server/api/files/files.types.ts index 28cb5ded2..bec868fa6 100644 --- a/apps/remix/server/api/files/files.types.ts +++ b/apps/remix/server/api/files/files.types.ts @@ -44,7 +44,7 @@ export type TGetEnvelopeItemFileDownloadRequestParams = z.infer { 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'); + }); }); diff --git a/packages/lib/client-only/download-pdf.ts b/packages/lib/client-only/download-pdf.ts index 3bde40884..5e7a11d3c 100644 --- a/packages/lib/client-only/download-pdf.ts +++ b/packages/lib/client-only/download-pdf.ts @@ -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; }; diff --git a/packages/lib/utils/envelope-download.ts b/packages/lib/utils/envelope-download.ts index c1e3a1e59..0ab5558b9 100644 --- a/packages/lib/utils/envelope-download.ts +++ b/packages/lib/utils/envelope-download.ts @@ -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 = | {