diff --git a/.agents/plans/smooth-ivory-sky-partial-signed-pdf-download.md b/.agents/plans/smooth-ivory-sky-partial-signed-pdf-download.md new file mode 100644 index 000000000..852102af8 --- /dev/null +++ b/.agents/plans/smooth-ivory-sky-partial-signed-pdf-download.md @@ -0,0 +1,94 @@ +--- +date: 2026-04-22 +title: Partial Signed Pdf Download +--- + +## Summary + +Let team members fetch a PDF with all currently-inserted fields burned in while the envelope is still in `PENDING` status. Today the only available bytes for a pending envelope are the original (no fields) - the sealed PDF only materialises after the last recipient signs and the `seal-document` job runs. + +Exposed in two places: + +- v2 API: `GET /api/v2/envelope/item/{envelopeItemId}/download?version=pending` (API-token auth) +- UI: a `Partial` button in the existing `EnvelopeDownloadDialog`, alongside `Original`. Replaces the `Signed` slot when the envelope is `PENDING`. Backed by the existing session-authed file route `GET /api/files/envelope/{envelopeId}/envelopeItem/{id}/download/pending`. + +## Scope + +- v2 API only (no v1). +- `internalVersion === 2` envelopes only. Legacy v1 returns 400 `ENVELOPE_LEGACY`. +- Team-side / owner only. No recipient-token download path - recipients have the in-app overlay viewer for verification, and a downloadable half-signed PDF is a leak vector for partially-executed contracts. Enforced both at the server (the recipient-token file route does not accept `pending`) and at the UI (the dialog hides the Partial button when a recipient token is set). +- No PKI signature, no certificate page, no audit log appendix - the response is explicitly not a final executed document. +- No watermark or banner text. The filename suffix (`_pending.pdf`), the `Cache-Control: no-store, private` header, and the absence of a PKI signature are sufficient to signal draft status. + +## Behaviour + +API response matrix (both `/api/v2/envelope/item/{id}/download?version=pending` and the UI-facing `/api/files/envelope/{envelopeId}/envelopeItem/{id}/download/pending`): + +| Envelope status | Response | +|---|---| +| `PENDING` (v2) | 200, PDF with currently-inserted fields burned in | +| `PENDING` (v1) | 400 `ENVELOPE_LEGACY` | +| `DRAFT` | 400 `ENVELOPE_DRAFT` | +| `COMPLETED` | 400 `ENVELOPE_COMPLETED` | +| `REJECTED` | 400 `ENVELOPE_REJECTED` | + +All v1-vs-v2 / status-mismatch errors are 4xx so callers can cleanly separate them from real server failures (5xx). Specifically v1 PENDING returns 400 not 501: 5xx is reserved for actual server problems, while "this envelope can't satisfy this request shape" is a client-addressable condition. + +Filename: `{title}_pending.pdf`. + +ETag is content-addressed over `sha256(envelope.status + sorted((field.id, field.customText, field.signature?.id, field.signature?.created) for inserted===true fields))`. Returns 304 on `If-None-Match` match. + +No persistent caching. Generated on-demand per request when ETag misses. + +Error response shape (envelope item v2 download route and the team-side file route): preserves the existing `{ error: }` field for backwards compatibility and adds `code: ` as a new field for callers that want to branch on it. The document download route (`/document/{documentId}/download`) is untouched. + +## UI + +`apps/remix/app/components/dialogs/envelope-download-dialog.tsx`: + +- The dialog shows `Original` plus one of: + - `Signed` when status is `COMPLETED` (existing behaviour) + - `Partial` when status is `PENDING`, there is no recipient token, and the envelope is not legacy (`!isLegacy`) + - nothing otherwise +- New optional prop `isLegacy?: boolean`. Only consulted to gate the `Partial` button, so callers whose status can never be `PENDING` (DRAFT/COMPLETED/REJECTED hardcoded, or `isComplete: true` matchers) and callers that always set a recipient token can omit it. Three call sites pass it (`isLegacy={envelope.internalVersion === 1}`): `documents-table-action-dropdown.tsx`, `envelope-editor.tsx`, `document-page-view-dropdown.tsx`. The other eight callers were left alone. + +Trade-off: a future team-side dialog usage where status could be PENDING but the dev forgets `isLegacy` will silently not render the Partial button. The status gate prevents an actively broken click; missing button is discoverable in testing. Required-prop alternative was rejected because eight of eleven call sites would carry a meaningless value. + +## Files + +Server: + +- `apps/remix/server/api/download/download.types.ts` - added `'pending'` to the `version` enum; split the validator into `param` (envelopeItemId) + `query` (version). The original wiring as a path-param validator was a pre-existing bug: requests like `?version=original` were silently returning the signed PDF since `version` actually arrives as a query string. Fixed as a side effect. +- `packages/trpc/server/envelope-router/download-envelope-item.types.ts` - mirrored the enum change in the OpenAPI schema. +- `apps/remix/server/api/download/download.ts` - the envelope item v2 route now fetches envelope recipients alongside the envelope, branches on `version` when calling the helper, and emits AppError responses as `{ error, code }` consistently across all status codes. +- `apps/remix/server/api/files/files.types.ts` - added `'pending'` to the team-side download schema only. The recipient-token download schema is untouched, so `/api/files/token/.../download/pending` is rejected by the schema validator. +- `apps/remix/server/api/files/files.ts` - the team-side download handler fetches envelope recipients and dispatches the `pending` branch through the same `handleEnvelopeItemFileRequest` helper. Wrapped in a try/catch that returns `{ error, code }` for AppErrors. +- `apps/remix/server/api/files/files.helpers.ts` - `handleEnvelopeItemFileRequest` is now a single entry point taking a discriminated-union options type. The static-file flow (`signed`/`original`) and the on-demand pending flow are private helpers in the same module. +- `packages/lib/server-only/pdf/generate-partial-signed-pdf.ts` (new) - small orchestrator that loads the original PDF, groups inserted fields by page, calls the existing `insertFieldInPDFV2` overlay helper for each page, flattens, and saves. +- `packages/lib/errors/app-error.ts` - added `ENVELOPE_DRAFT`, `ENVELOPE_COMPLETED`, `ENVELOPE_REJECTED`, `ENVELOPE_LEGACY` codes, all mapped to 400. The legacy-envelope case deliberately returns 4xx rather than 501 to keep "this resource can't satisfy this operation" distinct from real 5xx server failures in caller logs/metrics. + +Client: + +- `packages/lib/utils/envelope-download.ts` - `EnvelopeItemPdfUrlOptions` download variant now allows `'pending'` as a version. The recipient-token URL builder will produce a URL the server rejects, but the dialog gates on no-token at the call site. +- `packages/lib/client-only/download-pdf.ts` - `DocumentVersion` extended; filename suffix logic moved into a small switch (`_signed.pdf`, `_pending.pdf`, `.pdf`). +- `apps/remix/app/components/dialogs/envelope-download-dialog.tsx` - secondary download derivation with the new `Partial` branch, optional `isLegacy` prop. +- `apps/remix/app/components/tables/documents-table-action-dropdown.tsx`, `apps/remix/app/components/general/envelope-editor/envelope-editor.tsx`, `apps/remix/app/components/general/document/document-page-view-dropdown.tsx` - pass `isLegacy={envelope.internalVersion === 1}` (or `row.internalVersion === 1`) to the dialog. + +## Verification + +1. E2E (`packages/app-tests/e2e/api/v2/partial-signed-pdf-download.spec.ts`): + - Pending envelope, recipient 1 signs, API token download with `?version=pending` returns 200 + PDF; subsequent call with `If-None-Match: ` returns 304; after recipient 2 completes the envelope flips to `COMPLETED` and the same call returns 400 `ENVELOPE_COMPLETED`; `?version=signed` then succeeds. + - Draft envelope returns 400 `ENVELOPE_DRAFT`. + - `internalVersion === 1` pending envelope returns 400 `ENVELOPE_LEGACY`. + +2. `npx tsc --noEmit -p apps/remix/tsconfig.json` and `npm run lint`. + +3. Manual: open the Documents table or envelope editor on a PENDING envelope (v2), open the download dialog, confirm `Partial` appears alongside `Original` and produces a `_pending.pdf` with current fields burned in. Same dialog on a COMPLETED envelope shows `Signed`. Same dialog on a v1 PENDING envelope shows neither (status gate would show Partial, but the `isLegacy` flag filters it out). + +## Out of Scope / Follow-ups + +- Recipient-token download path (API and UI) - decided against. Revisit if there is concrete demand and a story for limiting the leak vector. +- v1 API parity / v1 partial rendering - not building. Implementing partial for v1 would require porting `legacy_insertFieldInPDF` / `insertFieldInPDFV1` into a partial-only flow, which is code with no long-term home as v1 is being phased out. +- Document download route (`/document/{documentId}/download`) - untouched. Same error shape and validator wiring as before. Consider normalising to the same `{ error, code }` shape in a follow-up if any caller wants to branch on `code` from that route. +- Persistent caching layer / job-queue generation - revisit if p95 latency on large PDFs becomes an issue. +- Specific toast for `ENVELOPE_LEGACY` in the dialog - currently the catch-all "Something went wrong" handles it. Worth a polish if v1 PENDING envelopes are common in your data and we see complaints. (Note: with the `isLegacy` gate at the UI, the error is unreachable from the dialog itself; the API can still surface it for direct callers.) diff --git a/apps/docs/content/docs/developers/embedding/authoring/v2.mdx b/apps/docs/content/docs/developers/embedding/authoring/v2.mdx index a21a4069c..92b3b580d 100644 --- a/apps/docs/content/docs/developers/embedding/authoring/v2.mdx +++ b/apps/docs/content/docs/developers/embedding/authoring/v2.mdx @@ -102,17 +102,18 @@ const EnvelopeEditor = ({ presignToken, envelopeId }) => { ### All V2 Authoring Components -| Prop | Type | Required | Description | -| ------------------ | --------- | -------- | -------------------------------------------------------- | -| `presignToken` | `string` | Yes | Authentication token from your backend | -| `externalId` | `string` | No | Your reference ID to link with the envelope | -| `host` | `string` | No | Custom host URL. Defaults to `https://app.documenso.com` | -| `css` | `string` | No | Custom CSS string (Platform Plan) | -| `cssVars` | `object` | No | [CSS variable](/docs/developers/embedding/css-variables) overrides (Platform Plan) | -| `darkModeDisabled` | `boolean` | No | Disable dark mode (Platform Plan) | -| `language` | `string` | No | Set the UI language. See [Supported Languages](https://github.com/documenso/documenso/tree/main/packages/lib/constants/locales.ts) | -| `className` | `string` | No | CSS class for the iframe | -| `features` | `object` | No | Feature toggles for the authoring experience | +| Prop | Type | Required | Description | +| ---------------- | --------- | -------- | -------------------------------------------------------- | +| `presignToken` | `string` | Yes | Authentication token from your backend | +| `externalId` | `string` | No | Your reference ID to link with the envelope | +| `host` | `string` | No | Custom host URL. Defaults to `https://app.documenso.com` | +| `css` | `string` | No | Custom CSS string (Platform Plan) | +| `cssVars` | `object` | No | [CSS variable](/docs/developers/embedding/css-variables) overrides (Platform Plan) | +| `darkModeDisabled` | `boolean` | No | Disable dark mode (Platform Plan) | +| `language` | `string` | No | Set the UI language. See [Supported Languages](https://github.com/documenso/documenso/tree/main/packages/lib/constants/locales.ts) | +| `className` | `string` | No | CSS class for the iframe | +| `user` | `object` | No | Current user info. When provided, enables the "Add Myself" button in the recipients list. Object with optional `email` and `name` fields | +| `features` | `object` | No | Feature toggles for the authoring experience | ### Create Component Only diff --git a/apps/docs/content/docs/users/documents/advanced/index.mdx b/apps/docs/content/docs/users/documents/advanced/index.mdx index 6b4572242..8ff904d66 100644 --- a/apps/docs/content/docs/users/documents/advanced/index.mdx +++ b/apps/docs/content/docs/users/documents/advanced/index.mdx @@ -29,4 +29,9 @@ description: Advanced document features including PDF placeholders, AI detection description="Set a signing deadline so document links expire after a configurable period." href="/docs/users/documents/advanced/recipient-expiration" /> + diff --git a/apps/docs/content/docs/users/documents/advanced/meta.json b/apps/docs/content/docs/users/documents/advanced/meta.json index 88dda8485..f56ea634c 100644 --- a/apps/docs/content/docs/users/documents/advanced/meta.json +++ b/apps/docs/content/docs/users/documents/advanced/meta.json @@ -6,6 +6,7 @@ "ai-detection", "default-recipients", "document-visibility", - "recipient-expiration" + "recipient-expiration", + "signing-reminders" ] } diff --git a/apps/docs/content/docs/users/documents/advanced/signing-reminders.mdx b/apps/docs/content/docs/users/documents/advanced/signing-reminders.mdx new file mode 100644 index 000000000..1f674526e --- /dev/null +++ b/apps/docs/content/docs/users/documents/advanced/signing-reminders.mdx @@ -0,0 +1,195 @@ +--- +title: Signing Reminders +description: Automatically email recipients who have not yet signed on a configurable schedule. +--- + +import { Callout } from 'fumadocs-ui/components/callout'; +import { Step, Steps } from 'fumadocs-ui/components/steps'; +import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; + +## Overview + +Signing reminders automatically email recipients who have not completed their signing. You configure when the first reminder goes out and how often it repeats — Documenso handles the rest until the recipient signs or the document leaves a pending state. + +This is useful when: + +- You want recipients nudged without having to track them down manually +- A deadline is approaching and you want to escalate gradually +- You send a high volume of documents and cannot chase each one + +Reminders are tracked **per recipient**, not per document. Each unsigned recipient is on their own schedule based on when they were emailed. + +This is different from the **Resend** action covered in [Send Documents](/docs/users/documents/send), which is a one-off manual nudge. Reminders are scheduled and recurring. + +## Default Behaviour + +Every **newly created** organisation starts with reminders **enabled**: + +- First reminder sent **5 days** after the recipient is emailed +- Repeats **every 2 days** until the recipient signs + +You can change this default at the organisation or team level, or override it per document. + + + Organisations created **before this feature shipped** have reminders left blank (no default) so + recipients of in-flight or future documents are not unexpectedly sent reminders. To enable + reminders for an existing organisation, configure **Default Signing Reminders** under + **Organisation Settings > Preferences > Document**. + + +## Settings Cascade + +Reminder settings follow a three-level cascade: **Organisation → Team → Document**. Each level can override the one above it, or inherit from it. + + + + +Sets the default for all teams in the organisation. Options are **Enabled** (set when the first reminder fires and how often it repeats) or **No reminders**. + +To configure, navigate to **Organisation Settings > Preferences > Document** and find **Default Signing Reminders**. + + + + +Overrides the organisation default for documents created within this team. Options are **Enabled**, **No reminders**, or **Inherit from organisation**. + +New teams default to **Inherit from organisation**. + +To configure, navigate to **Team Settings > Preferences > Document** and find **Default Signing Reminders**. + + + + +Overrides the team or organisation default for a single document. Options are **Enabled**, **No reminders**, or **Inherit from organisation**. + +If you do not change reminders when editing a document, the team or organisation default applies. + + + + +## Set Reminders for a Document + +{/* prettier-ignore */} + + + +### Open the document settings + +In the document editor, open the **Settings** dialog and go to the **Reminders** tab. + + + + +### Choose a mode + +- **Enabled** — Documenso sends reminders on the schedule you configure below +- **No reminders** — no automatic reminders for this document +- **Inherit from organisation** — use the team or organisation default + + + + +### Configure the schedule + +When **Enabled**, set: + +- **Send first reminder after** — a number and unit (days, weeks, or months) measured from when the recipient is first emailed +- **Then repeat every** — either **Custom interval** (a number and unit) or **Don't repeat** (only one reminder is ever sent) + + + + +### Send the document + +The first reminder is scheduled when the recipient receives the initial signing email. Subsequent reminders are scheduled from the time the previous reminder was sent. + + + + + + Editing reminder settings on a document that is already pending recalculates the next reminder for + every unsigned recipient immediately. + + +## Set a Default Reminder Schedule + +{/* prettier-ignore */} + + + +### Navigate to document preferences + +Go to **Organisation Settings > Preferences > Document** (or **Team Settings > Preferences > Document** for team-level overrides). + + + + +### Configure the default + +Find **Default Signing Reminders** and choose: + +- **Enabled** — set the first-reminder delay and repeat interval +- **No reminders** — disable reminders by default +- **Inherit from organisation** (team level only) — use whatever the organisation has configured + + + + +### Save + +Click **Save** to apply. New documents created after this change use the updated default. Documents already in flight are unaffected unless you edit their reminder settings directly. + + + + +## What Happens When a Reminder Fires + +When a recipient's next reminder time arrives: + +1. Documenso sends the reminder email — same template as the original signing request, but the subject and preview are prefixed with **"Reminder:"**. +2. An audit log entry is created for the recipient (an `EMAIL_SENT` entry of type `REMINDER`). +3. The `document.reminder.sent` webhook fires. See [webhook events](/docs/developers/webhooks/events). +4. The next reminder is scheduled based on **Then repeat every**, or no further reminder is scheduled if you chose **Don't repeat**. + +Reminders stop automatically when the recipient signs, declines, the recipient's signing deadline passes, or the document leaves the pending state (completed, rejected, or deleted). + +## When Reminders Are Skipped + +A configured reminder will not be sent in the following cases: + +- The recipient is a **CC** — CCs are notified once and never reminded +- The recipient's **signing deadline has passed** — see [Recipient Expiration](/docs/users/documents/advanced/recipient-expiration). Resending the document refreshes the deadline and resumes reminders. +- The document uses **manual link distribution** — Documenso never emails recipients for these documents +- The envelope's email settings have **signing request emails disabled** +- The recipient has not yet been emailed (for example, an unreached step in a sequential workflow) + + + Reminders stop automatically **30 days after the recipient was first emailed**, regardless of the + repeat interval. This hard cap prevents runaway reminder chains for recipients who never sign and + have no expiration set. If you need a different stop condition, set a shorter repeat interval, + use **Don't repeat**, or configure [recipient expiration](/docs/users/documents/advanced/recipient-expiration). + + + + Reminders are dispatched by a background sweep that runs every 15 minutes, so the actual send time + may be up to ~15 minutes after the scheduled time. + + +## Reminder Options Reference + +| Setting | Options | Notes | +| --------------------------- | ---------------------------------------- | -------------------------------------------------------------------- | +| **Mode** | Enabled / No reminders / Inherit | Inherit is only available at the team and document levels | +| **Send first reminder after** | 1+ days, weeks, or months | Measured from when the recipient receives the initial signing email. Reminders past 30 days from that moment are skipped. | +| **Then repeat every** | Custom interval (1+ days/weeks/months) or Don't repeat | Custom interval keeps reminding until the recipient signs or the 30-day cap is reached | + +The organisation default out of the box is **first reminder after 5 days, repeating every 2 days**, which falls well inside the 30-day cap. + +--- + +## See Also + +- [Send Documents](/docs/users/documents/send) - Send documents and trigger a one-off resend +- [Recipient Expiration](/docs/users/documents/advanced/recipient-expiration) - Set a hard signing deadline +- [Document Preferences](/docs/users/organisations/preferences/document) - Configure default document settings +- [Webhook Events](/docs/developers/webhooks/events) - Subscribe to `document.reminder.sent` diff --git a/apps/docs/content/docs/users/organisations/preferences/document.mdx b/apps/docs/content/docs/users/organisations/preferences/document.mdx index 4d6bab54b..1f4e82c08 100644 --- a/apps/docs/content/docs/users/organisations/preferences/document.mdx +++ b/apps/docs/content/docs/users/organisations/preferences/document.mdx @@ -33,6 +33,7 @@ To access the preferences, navigate to either the organisation or teams settings | **Include the Audit Logs** | Whether the audit logs are embedded in the document when downloaded. The audit logs are always available separately from the logs page. | | **Default Recipients** | Recipients that are automatically added to new documents. Can be overridden per document. | | **Default Envelope Expiration** | How long recipients have to sign before the signing link expires. See [recipient expiration](/docs/users/documents/advanced/recipient-expiration). | +| **Default Signing Reminders** | When and how often to email recipients who have not yet signed. See [signing reminders](/docs/users/documents/advanced/signing-reminders). | | **Delegate Document Ownership** | Allow team API tokens to delegate document ownership to another team member. | | **AI Features** | Enable AI-powered features such as automatic recipient detection. Only shown if AI features are configured on the instance. | diff --git a/apps/remix/app/components/dialogs/envelope-download-dialog.tsx b/apps/remix/app/components/dialogs/envelope-download-dialog.tsx index 66a291057..eba2da46f 100644 --- a/apps/remix/app/components/dialogs/envelope-download-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelope-download-dialog.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { useLingui } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro'; @@ -24,6 +24,19 @@ type EnvelopeItemToDownload = Pick