diff --git a/.agents/plans/wild-indigo-wave-rejected-expired-recipient-filters.md b/.agents/plans/wild-indigo-wave-rejected-expired-recipient-filters.md new file mode 100644 index 0000000000..b8e9fb39cc --- /dev/null +++ b/.agents/plans/wild-indigo-wave-rejected-expired-recipient-filters.md @@ -0,0 +1,146 @@ +--- +date: 2026-05-28 +title: Rejected Expired Recipient Filters +--- + +## Context + +Customers need to find (a) envelopes/documents in the `REJECTED` state and (b) envelopes +with at least one recipient whose signing link has **expired**. Today the UI only exposes +`INBOX / PENDING / COMPLETED / DRAFT / ALL` tabs, and the public API has no way to filter by +expired recipient links — forcing a fetch-all-`PENDING`-then-inspect-each-recipient workaround. + +Two key facts from exploration shaped this plan: + +- **`REJECTED` is already fully wired in the backend** — the where-clause (`find-documents.ts`), + stats counts (`get-stats.ts`), tRPC response schema, `ExtendedDocumentStatus` enum, and the + `FRIENDLY_STATUS_MAP` display all handle it. It is simply absent from the UI tab array. +- **Renewing expired links already works.** `resendDocument` refreshes `expiresAt` and clears + `expirationNotifiedAt` for unsigned, non-CC recipients (`resend-document.ts:98-121`), exposed + publicly via `POST /api/v2/document/redistribute` and `/api/v2/envelope/redistribute` and via the + resend/redistribute UI dialogs. No new renew mechanism is needed — only documentation/wording. + +Expiration is a per-recipient condition (not an envelope status). The approved design models it +in the UI as an `EXPIRED` **pseudo-status tab** (reusing the existing tab machinery, mirroring how +`REJECTED` works) and in the public API as an orthogonal boolean `hasExpiredRecipients`. Both share +one EXISTS predicate. + +Definition of "expired recipient" (matches `isRecipientExpired`, `packages/lib/utils/recipients.ts:118`): +a `Recipient` with `expiresAt IS NOT NULL AND expiresAt <= now() AND signingStatus = NOT_SIGNED AND role != CC`. + +## Approach + +### A. Shared EXISTS predicate (reused 4x, justified) +Add a local `hasExpiredRecipient(eb)` helper — modeled on the existing per-file `recipientExists` / +`senderEmailIs` helpers — to `find-documents.ts`, `get-stats.ts`, and `find-envelopes.ts`. It is the +single source of truth for the expired condition above (using `new Date()` for `now`, matching the +`period` filter's `.toJSDate()` style). + +### B. REJECTED tab (UI only — backend already done) +- `apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx`: add + `ExtendedDocumentStatus.REJECTED` to the tab array (lines 149-155). Count badge, highlight, and + `?status=REJECTED` filtering already work via existing machinery. + +### C. EXPIRED pseudo-status (UI + internal stats) +1. `packages/prisma/types/extended-document-status.ts`: add `EXPIRED: 'EXPIRED'`. Internal-only — + the public `DocumentStatus` enum is unaffected. This intentionally surfaces TS errors at the three + exhaustive/`Record` sites below, forcing them to be handled. +2. `packages/lib/server-only/document/find-documents.ts`: + - Add `.with(ExtendedDocumentStatus.EXPIRED, ...)` to **both** `applyPersonalFilters` and + `applyTeamFilters`, mirroring the `COMPLETED` branch's access control (deleted + visibility + + owner/recipient access) with `hasExpiredRecipient(eb)` AND-ed in. Do **not** constrain + `Envelope.status` — the EXISTS already restricts to unsigned recipients. +3. `packages/lib/server-only/document/get-stats.ts`: + - Add an `expiredQuery` mirroring `pendingQuery`'s access control + `hasExpiredRecipient(eb)`. + - Add it to the `Promise.all`, add `[ExtendedDocumentStatus.EXPIRED]: expired` to the `stats` + record. **Do not** add `expired` to the `all` sum (it overlaps `PENDING`). +4. `packages/trpc/server/document-router/find-documents-internal.types.ts`: add + `[ExtendedDocumentStatus.EXPIRED]: z.number()` to the `stats` response object. (`status` already + accepts the extended enum via `z.nativeEnum(ExtendedDocumentStatus)`.) +5. `apps/remix/app/components/general/document/document-status.tsx`: add an `EXPIRED` entry to + `FRIENDLY_STATUS_MAP` — `label: msg` Expired, an icon (e.g. lucide `TimerOff`, matching the + `/sign/$token/expired` page), and a distinct color (e.g. `text-orange-500`) to differentiate from + `REJECTED` (red). +6. `documents._index.tsx`: add `[ExtendedDocumentStatus.EXPIRED]: 0` to the `stats` `useState` + initializer and `ExtendedDocumentStatus.EXPIRED` to the tab array. Final order: + `INBOX, PENDING, COMPLETED, DRAFT, REJECTED, EXPIRED, ALL`. +7. (Optional, recommended) `apps/remix/app/components/tables/documents-table-empty-state.tsx`: add + tailored `EXPIRED` and `REJECTED` empty-state copy (currently both fall through to `.otherwise()`). + +### D. Public API boolean `hasExpiredRecipients` (document + envelope, v2) +1. `packages/lib/server-only/document/find-documents.ts`: add `hasExpiredRecipients?: boolean` to + `FindDocumentsOptions`; when true, apply `.where((eb) => hasExpiredRecipient(eb))` inside + `buildBaseQuery` (orthogonal/additive to any `status`). +2. `packages/trpc/server/document-router/find-documents.types.ts`: add a query-safe boolean + `hasExpiredRecipients` to `ZFindDocumentsRequestSchema` with a `.describe(...)`. Mirror the + existing boolean-query-param handling in `find-document-audit-logs.types.ts` + (`filterForRecentActivity`) — avoid raw `z.coerce.boolean()` (the "false" -> true footgun); use a + string transform if needed. Pass it through in `find-documents.ts` (public handler). +3. `packages/lib/server-only/envelope/find-envelopes.ts`: add `hasExpiredRecipients?: boolean` to + `FindEnvelopesOptions` + the `hasExpiredRecipient(eb)` helper + the additive `.where`. +4. `packages/trpc/server/envelope-router/find-envelopes.types.ts`: add the same param to + `ZFindEnvelopesRequestSchema`; pass it through in the envelope-router find handler. + The param auto-appears in the generated `/api/v2/openapi.json`. + +Note: REST v1 `GET /api/v1/documents` is deprecated and lacks status filtering — left unchanged. +`REJECTED` is already a valid public `status` value (`DocumentStatus.REJECTED`), so no API change is +needed for rejected filtering. + +### E. Renew expired links — documentation only +No functional change. Document that resending renews expired links: +- Update the `.description` in `packages/trpc/server/document-router/redistribute-document.types.ts` + and `packages/trpc/server/envelope-router/redistribute-envelope.types.ts` to state that + redistributing refreshes the signing-link expiration for unsigned recipients. +- Optionally adjust resend/redistribute dialog copy + (`apps/remix/app/components/dialogs/document-resend-dialog.tsx`, + `envelope-redistribute-dialog.tsx`) to mention it renews expired links. + +## Files To Modify (summary) + +| Area | File | +|------|------| +| Enum | `packages/prisma/types/extended-document-status.ts` | +| Where-clause + API option | `packages/lib/server-only/document/find-documents.ts` | +| Stats counts | `packages/lib/server-only/document/get-stats.ts` | +| Envelope find (API) | `packages/lib/server-only/envelope/find-envelopes.ts` | +| Internal tRPC stats schema | `packages/trpc/server/document-router/find-documents-internal.types.ts` | +| Public doc API schema + handler | `packages/trpc/server/document-router/find-documents.types.ts`, `find-documents.ts` | +| Public envelope API schema + handler | `packages/trpc/server/envelope-router/find-envelopes.types.ts`, `find-envelopes.ts` | +| Status display | `apps/remix/app/components/general/document/document-status.tsx` | +| Tabs + stats init | `apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx` | +| Empty state (optional) | `apps/remix/app/components/tables/documents-table-empty-state.tsx` | +| Renew docs | `redistribute-document.types.ts`, `redistribute-envelope.types.ts` (+ resend dialogs, optional) | + +## Reused Utilities / Patterns +- `recipientExists` / `senderEmailIs` (per-file Kysely EXISTS helpers) — the template for the new + `hasExpiredRecipient` helper. +- `REJECTED` branches in `find-documents.ts` (lines 279, 416) and `rejectedQuery` in `get-stats.ts` + (line 227) — the template for the `EXPIRED` branches / `expiredQuery`. +- `isRecipientExpired` (`packages/lib/utils/recipients.ts:118`) — defines the `expiresAt <= now` + semantics to match. +- Existing tab machinery in `documents._index.tsx` (`getTabHref`, count badge, personal-org `.filter`) + — works unchanged for the new tabs. +- `resendDocument` / `trpc.document.redistribute` / `trpc.envelope.redistribute` — existing renew path. + +## Verification +1. **Typecheck** (the enum change forces all exhaustive/Record sites): `npm run typecheck -w @documenso/remix`. +2. **Seed + UI** (dev server already running): seed a team via `seedTeam`, send a document, then: + - Reject one as a recipient -> it appears under the new **Rejected** tab with a count. + - Force expiry (set a recipient `expiresAt` in the past, e.g. via Prisma Studio or a short + `envelopeExpirationPeriod`) -> the doc appears under the new **Expired** tab with a count, and the + count excludes signed/CC recipients. +3. **Public API**: `GET /api/v2/document?hasExpiredRecipients=true` and + `GET /api/v2/envelope?hasExpiredRecipients=true` (Bearer API token) return only envelopes with >=1 + expired unsigned recipient; confirm `GET /api/v2/document?status=REJECTED` works. Verify the param + appears in `/api/v2/openapi.json`. +4. **Renew**: on an expired doc, run resend/redistribute (UI dialog or + `POST /api/v2/document/redistribute`) -> recipient `expiresAt` is refreshed, the doc leaves the + Expired tab, and the signing link no longer redirects to `/sign/$token/expired`. +5. **E2E** (optional): extend `packages/app-tests/e2e/envelopes/envelope-expiration-send.spec.ts` + with an Expired-tab assertion. +6. Do **not** modify/commit `packages/lib/translations/*.po`; run `npm run translate` only if needed + for new `msg`/`Trans` strings, and keep generated `.po` files out of the branch. + +## Open Questions +- Exact icon/color for the `EXPIRED` tab (proposed: `TimerOff`, `text-orange-500`). +- Whether to add the optional tailored empty-state copy now or defer. \ No newline at end of file diff --git a/.agents/skills/create-justification/SKILL.md b/.agents/skills/create-justification/SKILL.md deleted file mode 100644 index 78a2aaea94..0000000000 --- a/.agents/skills/create-justification/SKILL.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -name: create-justification -description: Create a new justification file in .agents/justifications/ with a unique three-word ID, frontmatter, and formatted title -license: MIT -compatibility: opencode -metadata: - audience: agents - workflow: decision-making ---- - -## What I do - -I help you create new justification files in the `.agents/justifications/` directory. Each justification file gets: - -- A unique three-word identifier (e.g., `swift-emerald-river`) -- Frontmatter with the current date and formatted title -- Content you provide - -## How to use - -Run the script with a slug and content: - -```bash -npx tsx scripts/create-justification.ts "decision-name" "Justification content here" -``` - -Or use heredoc for multi-line content: - -```bash -npx tsx scripts/create-justification.ts "decision-name" << HEREDOC -Multi-line -justification content -goes here -HEREDOC -``` - -## File format - -Files are created as: `{three-word-id}-{slug}.md` - -Example: `swift-emerald-river-decision-name.md` - -The file includes frontmatter: - -```markdown ---- -date: 2026-01-13 -title: Decision Name ---- - -Your content here -``` - -## When to use me - -Use this skill when you need to document the reasoning or justification for a decision, approach, or architectural choice. The unique ID ensures no filename conflicts, and the frontmatter provides metadata for organization. diff --git a/.agents/skills/create-plan/SKILL.md b/.agents/skills/create-plan/SKILL.md deleted file mode 100644 index 8ceb2ef8c9..0000000000 --- a/.agents/skills/create-plan/SKILL.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -name: create-plan -description: Create a new plan file in .agents/plans/ with a unique three-word ID, frontmatter, and formatted title -license: MIT -compatibility: opencode -metadata: - audience: agents - workflow: planning ---- - -## What I do - -I help you create new plan files in the `.agents/plans/` directory. Each plan file gets: - -- A unique three-word identifier (e.g., `happy-blue-moon`) -- Frontmatter with the current date and formatted title -- Content you provide - -## How to use - -Run the script with a slug and content: - -```bash -npx tsx scripts/create-plan.ts "feature-name" "Plan content here" -``` - -Or use heredoc for multi-line content: - -```bash -npx tsx scripts/create-plan.ts "feature-name" << HEREDOC -Multi-line -plan content -goes here -HEREDOC -``` - -## File format - -Files are created as: `{three-word-id}-{slug}.md` - -Example: `happy-blue-moon-feature-name.md` - -The file includes frontmatter: - -```markdown ---- -date: 2026-01-13 -title: Feature Name ---- - -Your content here -``` - -## When to use me - -Use this skill when you need to create a new plan document for a feature, task, or project. The unique ID ensures no filename conflicts, and the frontmatter provides metadata for organization. diff --git a/.agents/skills/create-scratch/SKILL.md b/.agents/skills/create-scratch/SKILL.md deleted file mode 100644 index e44e4779d0..0000000000 --- a/.agents/skills/create-scratch/SKILL.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -name: create-scratch -description: Create a new scratch file in .agents/scratches/ with a unique three-word ID, frontmatter, and formatted title -license: MIT -compatibility: opencode -metadata: - audience: agents - workflow: exploration ---- - -## What I do - -I help you create new scratch files in the `.agents/scratches/` directory. Each scratch file gets: - -- A unique three-word identifier (e.g., `calm-teal-cloud`) -- Frontmatter with the current date and formatted title -- Content you provide - -## How to use - -Run the script with a slug and content: - -```bash -npx tsx scripts/create-scratch.ts "note-name" "Scratch content here" -``` - -Or use heredoc for multi-line content: - -```bash -npx tsx scripts/create-scratch.ts "note-name" << HEREDOC -Multi-line -scratch content -goes here -HEREDOC -``` - -## File format - -Files are created as: `{three-word-id}-{slug}.md` - -Example: `calm-teal-cloud-note-name.md` - -The file includes frontmatter: - -```markdown ---- -date: 2026-01-13 -title: Note Name ---- - -Your content here -``` - -## When to use me - -Use this skill when you need to create a temporary note, exploration document, or scratch pad for ideas. The unique ID ensures no filename conflicts, and the frontmatter provides metadata for organization. diff --git a/.github/ISSUE_TEMPLATE/deploy-provider-request.yml b/.github/ISSUE_TEMPLATE/deploy-provider-request.yml new file mode 100644 index 0000000000..c591543916 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/deploy-provider-request.yml @@ -0,0 +1,61 @@ +name: 'One-Click Deploy Provider Request' +description: Request a new one-click deployment provider (Railway, Render, etc.) to be added to our README +title: 'One-Click Deploy Provider Request: [Provider Name]' +labels: ['deploy-provider-request'] +body: + - type: markdown + attributes: + value: | + Thanks for your interest in adding a one-click deploy option for Documenso! + + Each provider we list requires us to create, test, and maintain a deployment template, which is ongoing work on top of everything else. To keep this manageable, we ask that providers (or users) **open an issue instead of a PR** so the community can signal interest. + + **How this works:** + + - 👍 this issue if you'd like to see Documenso deployable on this provider. + - If community interest is high enough, we'll consider adding it to the README. + - Opening an issue is not a guarantee of inclusion. PRs adding badges without a prior issue and demonstrated interest will be closed. + - type: input + attributes: + label: Provider Name + placeholder: e.g. Railway + validations: + required: true + - type: input + attributes: + label: Provider Website + placeholder: e.g. https://railway.com + validations: + required: true + - type: input + attributes: + label: Deploy/Template URL + description: A link to an existing deployment template or deploy button URL, if one exists. + - type: dropdown + attributes: + label: Who creates and maintains the deployment template? + options: + - The provider + - Me / the community + - Nobody yet + validations: + required: true + - type: textarea + attributes: + label: Testing & Maintenance + description: Has the template been tested against the current Documenso release? How are updates handled when Documenso ships breaking changes (env vars, migrations, Docker changes)? + validations: + required: true + - type: textarea + attributes: + label: Why this provider? + description: Tell us why Documenso users would benefit — existing user base, region coverage, free tier, etc. + validations: + required: true + - type: checkboxes + attributes: + label: Please check the boxes that apply to this request. + options: + - label: I have searched existing issues to make sure this provider has not already been requested. + - label: I understand that inclusion depends on community interest and is not guaranteed. + - label: I understand that PRs adding deploy badges without a prior issue will be closed. diff --git a/.github/actions/node-install/action.yml b/.github/actions/node-install/action.yml index b01a28740a..fb208e916f 100644 --- a/.github/actions/node-install/action.yml +++ b/.github/actions/node-install/action.yml @@ -2,7 +2,7 @@ name: 'Setup node' inputs: node_version: required: false - default: v22.x + default: v24.x runs: using: 'composite' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55ed7f27dd..e3b1007da9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,7 @@ jobs: build_app: name: Build App runs-on: ubuntu-latest + timeout-minutes: 60 steps: - name: Checkout uses: actions/checkout@v4 @@ -32,6 +33,7 @@ jobs: build_docker: name: Build Docker Image runs-on: ubuntu-latest + timeout-minutes: 60 steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index d74f30387c..b5ac9017a4 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -11,6 +11,7 @@ jobs: analyze: name: Analyze runs-on: ubuntu-latest + timeout-minutes: 60 permissions: actions: read contents: read diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 80d1889648..00132070db 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -8,6 +8,7 @@ on: jobs: deploy: runs-on: ubuntu-latest + timeout-minutes: 60 steps: - name: Checkout code diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml index 34d7a478f9..d8589bf1b4 100644 --- a/.github/workflows/issue-labeler.yml +++ b/.github/workflows/issue-labeler.yml @@ -7,6 +7,7 @@ on: jobs: label-when-assigned: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Label issue uses: actions/github-script@v6 diff --git a/.github/workflows/issue-opened.yml b/.github/workflows/issue-opened.yml index 92b559d11e..fd4a601512 100644 --- a/.github/workflows/issue-opened.yml +++ b/.github/workflows/issue-opened.yml @@ -7,6 +7,7 @@ on: jobs: label_issues: runs-on: ubuntu-latest + timeout-minutes: 10 permissions: issues: write steps: diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index 15fe7cbfa4..5c3eeaac25 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -13,6 +13,7 @@ jobs: contents: read pull-requests: write runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/labeler@v4 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 50137d2e15..f28fc4e1fe 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,6 +14,7 @@ jobs: build_and_publish_platform_containers: name: Build and publish platform containers runs-on: ${{ matrix.os }} + timeout-minutes: 60 strategy: fail-fast: false matrix: @@ -78,6 +79,7 @@ jobs: create_and_publish_manifest: name: Create and publish manifest runs-on: ubuntu-latest + timeout-minutes: 60 needs: build_and_publish_platform_containers steps: - name: Checkout diff --git a/.github/workflows/semantic-pull-requests.yml b/.github/workflows/semantic-pull-requests.yml index 0dab3392d6..34e7314368 100644 --- a/.github/workflows/semantic-pull-requests.yml +++ b/.github/workflows/semantic-pull-requests.yml @@ -15,6 +15,7 @@ jobs: validate-pr: name: Validate PR title runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: amannn/action-semantic-pull-request@v5 id: lint_pr_title diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index c9c12ce59c..aed53da8c7 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -7,6 +7,7 @@ on: jobs: stale: runs-on: ubuntu-latest + timeout-minutes: 10 permissions: issues: write pull-requests: write diff --git a/.npmrc b/.npmrc index 75baad7f05..cbc6b6537f 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,3 @@ legacy-peer-deps = true prefer-dedupe = true -# min-release-age = 7 +min-release-age = 7 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 04e96f31de..6682e1e68c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -89,8 +89,8 @@ Documenso is an open-source document signing platform built as a **monorepo** us | Package | Description | Port | | -------------------------- | -------------------------------------------------------- | ---- | | `@documenso/remix` | Main application - React Router (Remix) with Hono server | 3000 | -| `@documenso/documentation` | Documentation site (Next.js + Nextra) | 3002 | | `@documenso/openpage-api` | Public analytics API | 3003 | +| `@documenso/docs` | Documentation site | 3004 | ### Core Packages (`packages/`) diff --git a/README.md b/README.md index ff23805ef7..b8862b2798 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Simple, secure document signing for DataThink's internal teams and products. -KeepContracts is a white-labeled, self-hosted document signing service built on top of [Documenso](https://documenso.com) (v2.11.0) and maintained by [DataThink](https://datathink.dev). +KeepContracts is a white-labeled, self-hosted document signing service built on top of [Documenso](https://documenso.com) (v2.18.0) and maintained by [DataThink](https://datathink.dev). ## About @@ -87,7 +87,7 @@ npm run prisma:seed ## Upstream -This project is a fork of [documenso/documenso](https://github.com/documenso/documenso) at v2.11.0, licensed under AGPLv3. Upstream documentation is available at [docs.documenso.com](https://docs.documenso.com). +This project is a fork of [documenso/documenso](https://github.com/documenso/documenso) at v2.18.0, licensed under AGPLv3. Upstream documentation is available at [docs.documenso.com](https://docs.documenso.com). ## Support diff --git a/apps/docs/content/docs/developers/api/documents.mdx b/apps/docs/content/docs/developers/api/documents.mdx index a21a2740bd..bd526e8288 100644 --- a/apps/docs/content/docs/developers/api/documents.mdx +++ b/apps/docs/content/docs/developers/api/documents.mdx @@ -6,6 +6,8 @@ description: Create, manage, and send documents for signing via the API. import { Callout } from 'fumadocs-ui/components/callout'; import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; + + This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference, see the [OpenAPI Reference](https://openapi.documenso.com). @@ -26,35 +28,62 @@ Each document contains one or more PDF files, a list of recipients, and the fiel A document object contains the following properties: -| Property | Type | Description | -| --------------- | -------------- | -------------------------------------------------------------- | -| `id` | string | Unique identifier (e.g., `envelope_abc123`) | -| `type` | string | `DOCUMENT` or `TEMPLATE` | -| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, or `REJECTED` | -| `title` | string | Document title | -| `source` | string | How the document was created: `DOCUMENT`, `TEMPLATE`, `API` | -| `visibility` | string | Who can view: `EVERYONE`, `ADMIN`, `MANAGER_AND_ABOVE` | -| `externalId` | string \| null | Your custom identifier for the document | -| `createdAt` | string | ISO 8601 timestamp | -| `updatedAt` | string | ISO 8601 timestamp | -| `completedAt` | string \| null | Timestamp when all recipients completed signing | -| `deletedAt` | string \| null | Timestamp if soft-deleted | -| `recipients` | array | List of recipients and their signing status | -| `fields` | array | Signature and form fields on the document | -| `envelopeItems` | array | PDF files attached to the document | -| `documentMeta` | object | Email settings, redirect URL, signing options | +| Property | Type | Description | +| ------------------- | -------------- | -------------------------------------------------------------------------------------------- | +| `id` | string | Unique identifier (e.g., `envelope_abc123`) | +| `secondaryId` | string | Legacy identifier in prefixed form (`document_123` for documents, `template_123` for templates) | +| `internalVersion` | number | Internal envelope schema version | +| `type` | string | `DOCUMENT` or `TEMPLATE` | +| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, or `CANCELLED` | +| `title` | string | Document title | +| `source` | string | How the document was created: `DOCUMENT`, `TEMPLATE`, `TEMPLATE_DIRECT_LINK` | +| `visibility` | string | Who can view: `EVERYONE`, `ADMIN`, `MANAGER_AND_ABOVE` | +| `templateType` | string | Template visibility: `PUBLIC`, `PRIVATE`, or `ORGANISATION` (only meaningful for templates) | +| `externalId` | string \| null | Your custom identifier for the document | +| `userId` | number | ID of the user who owns the document | +| `teamId` | number | ID of the team the document belongs to | +| `folderId` | string \| null | ID of the folder containing the document | +| `templateId` | number \| null | Legacy ID of the template this document was created from | +| `authOptions` | object \| null | Access and action authentication requirements | +| `formValues` | object \| null | Pre-filled form values | +| `publicTitle` | string | Public title shown on profile and direct-link pages | +| `publicDescription` | string | Public description shown on profile and direct-link pages | +| `createdAt` | string | ISO 8601 timestamp | +| `updatedAt` | string | ISO 8601 timestamp | +| `completedAt` | string \| null | Timestamp when all recipients completed signing | +| `deletedAt` | string \| null | Timestamp if soft-deleted | +| `recipients` | array | List of recipients and their signing status | +| `fields` | array | Signature and form fields on the document | +| `envelopeItems` | array | PDF files attached to the document | +| `directLink` | object \| null | Direct-link signing configuration (`id`, `token`, `enabled`, `directTemplateRecipientId`) | +| `team` | object | Owning team (`id`, `url`) | +| `user` | object | Document owner (`id`, `name`, `email`) | +| `documentMeta` | object | Email settings, redirect URL, signing options | + +Documents created through the API have `source: "DOCUMENT"` — there is no separate `API` source value. To tag documents created by your integration, set `externalId` when creating them. ### Example Document Object ```json { "id": "envelope_abc123xyz", + "secondaryId": "document_123", + "internalVersion": 2, "type": "DOCUMENT", "status": "PENDING", - "source": "API", + "source": "DOCUMENT", "visibility": "EVERYONE", + "templateType": "PRIVATE", "title": "Service Agreement", "externalId": "contract-2025-001", + "userId": 1, + "teamId": 1, + "folderId": null, + "templateId": null, + "authOptions": null, + "formValues": null, + "publicTitle": "", + "publicDescription": "", "createdAt": "2025-01-15T10:30:00.000Z", "updatedAt": "2025-01-15T10:35:00.000Z", "completedAt": null, @@ -71,23 +100,41 @@ A document object contains the following properties: ], "fields": [ { - "id": "field_123", + "id": 123, + "secondaryId": "field_abc123", "type": "SIGNATURE", + "recipientId": 1, + "envelopeId": "envelope_abc123xyz", + "envelopeItemId": "envelope_item_xyz", "page": 1, - "positionX": 10, - "positionY": 80, - "width": 30, - "height": 5, - "recipientId": 1 + "positionX": "10", + "positionY": "80", + "width": "30", + "height": "5", + "customText": "", + "inserted": false, + "fieldMeta": null } ], "envelopeItems": [ { "id": "envelope_item_xyz", + "envelopeId": "envelope_abc123xyz", + "documentDataId": "doc_data_abc123", "title": "contract.pdf", "order": 1 } ], + "directLink": null, + "team": { + "id": 1, + "url": "your-team" + }, + "user": { + "id": 1, + "name": "Jane Smith", + "email": "jane@example.com" + }, "documentMeta": { "subject": "Please sign this document", "message": "Hi, please review and sign this agreement.", @@ -97,6 +144,8 @@ A document object contains the following properties: } ``` +Field position and size values are stored as decimals and serialized as strings in API responses. + ## List Documents Retrieve a paginated list of documents. @@ -112,7 +161,7 @@ GET /envelope | `page` | integer | Page number (default: 1) | | `perPage` | integer | Results per page (default: 10, max: 100) | | `type` | string | Filter by `DOCUMENT` or `TEMPLATE` | -| `status` | string | Filter by status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED` | +| `status` | string | Filter by status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, `CANCELLED` | | `source` | string | Filter by creation source | | `folderId` | string | Filter by folder ID | | `orderByColumn` | string | Sort field (only `createdAt` supported) | @@ -152,8 +201,8 @@ const response = await fetch(`${BASE_URL}/envelope`, { }, }); -const { data, pagination } = await response.json(); -console.log(`Found ${pagination.totalItems} documents`); +const { data, count } = await response.json(); +console.log(`Found ${count} documents`); // Filter by status const pendingResponse = await fetch( @@ -195,12 +244,10 @@ const pendingDocs = await pendingResponse.json(); ] } ], - "pagination": { - "page": 1, - "perPage": 10, - "totalPages": 5, - "totalItems": 42 - } + "count": 42, + "currentPage": 1, + "perPage": 10, + "totalPages": 5 } ``` @@ -626,6 +673,72 @@ The response includes signing URLs for each recipient: --- +## Cancel Document + +Cancel a pending document. This changes its status from `PENDING` to `CANCELLED`. + +``` +POST /envelope/cancel +``` + +### Request Body + +| Field | Type | Required | Description | +| ------------ | ------ | -------- | ----------------------------------- | +| `envelopeId` | string | Yes | Document ID | +| `reason` | string | No | Reason for cancelling the document | + +### Code Examples + + + +```bash +curl -X POST "https://app.documenso.com/api/v2/envelope/cancel" \ + -H "Authorization: api_xxxxxxxxxxxxxxxx" \ + -H "Content-Type: application/json" \ + -d '{ + "envelopeId": "envelope_abc123", + "reason": "The agreement is no longer needed." + }' +``` + + +```typescript +const response = await fetch('https://app.documenso.com/api/v2/envelope/cancel', { + method: 'POST', + headers: { + Authorization: 'api_xxxxxxxxxxxxxxxx', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + envelopeId: 'envelope_abc123', + reason: 'The agreement is no longer needed.', + }), +}); + +const { success } = await response.json(); +``` + + + +### Response + +```json +{ + "success": true +} +``` + +### Behavior + +- Only documents in `PENDING` status can be cancelled. Other statuses return `400`. +- Cancellation is not idempotent. Cancelling the same document again returns `400`. +- The document owner and team members with `MANAGER` or higher permissions can cancel it. Requests for documents you cannot view return `404`; requests for visible documents without sufficient permissions return `401`. +- A successful cancellation fires the `DOCUMENT_CANCELLED` webhook. +- Cancellation emails are sent only to eligible non-CC, non-rejected recipients who were sent or opened the document. + +--- + ## Delete Document Delete a document. Completed documents cannot be deleted. @@ -668,7 +781,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/delete', const { success } = await response.json(); -```` +``` @@ -678,7 +791,7 @@ const { success } = await response.json(); { "success": true } -```` +``` --- @@ -692,9 +805,11 @@ POST /envelope/get-many ### Request Body -| Field | Type | Required | Description | -| ------------- | ----- | -------- | --------------------- | -| `envelopeIds` | array | Yes | Array of document IDs | +| Field | Type | Required | Description | +| ---------- | ------ | -------- | ---------------------------------------------------------------------------- | +| `ids` | object | Yes | ID selector containing `type` and `ids` | +| `ids.type` | string | Yes | `envelopeId`, `documentId`, or `templateId` | +| `ids.ids` | array | Yes | 1-20 IDs: strings for `envelopeId`; numbers for `documentId` or `templateId` | ### Code Examples @@ -705,12 +820,17 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/get-many" \ -H "Authorization: api_xxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ - "envelopeIds": ["envelope_abc123", "envelope_def456", "envelope_ghi789"] + "ids": { + "type": "envelopeId", + "ids": ["envelope_abc123", "envelope_def456", "envelope_ghi789"] + } }' ``` ```typescript +const requestedIds = ['envelope_abc123', 'envelope_def456', 'envelope_ghi789']; + const response = await fetch('https://app.documenso.com/api/v2/envelope/get-many', { method: 'POST', headers: { @@ -718,16 +838,36 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/get-many 'Content-Type': 'application/json', }, body: JSON.stringify({ - envelopeIds: ['envelope_abc123', 'envelope_def456', 'envelope_ghi789'], + ids: { + type: 'envelopeId', + ids: requestedIds, + }, }), }); -const documents = await response.json(); +const { data } = await response.json(); -```` +``` +### Response + +```json +{ + "data": [ + { + "id": "envelope_abc123", + "type": "DOCUMENT", + "status": "PENDING", + "title": "Service Agreement" + } + ] +} +``` + +The endpoint silently omits envelopes you cannot access instead of returning `404`. Compare `data.length` with `requestedIds.length` to detect omissions. + --- ## Document Statuses @@ -738,6 +878,7 @@ const documents = await response.json(); | `PENDING` | Document has been sent. Waiting for recipients to sign. | | `COMPLETED` | All recipients have signed. Document is sealed. | | `REJECTED` | A recipient rejected the document. | +| `CANCELLED` | The document was cancelled by its owner or a team member with `MANAGER` or higher permissions. | ### Status Transitions @@ -745,11 +886,13 @@ const documents = await response.json(); flowchart LR DRAFT --> PENDING --> COMPLETED PENDING --> REJECTED + PENDING --> CANCELLED ``` - **DRAFT to PENDING**: Call the distribute endpoint - **PENDING to COMPLETED**: All recipients complete their signing - **PENDING to REJECTED**: A recipient rejects the document +- **PENDING to CANCELLED**: The document owner or a team member with `MANAGER` or higher permissions cancels the document You cannot modify recipients or fields after a document moves to `PENDING` status. @@ -771,8 +914,8 @@ flowchart LR | Parameter | Values | Description | | ---------- | ------------------------------------------- | ------------------------- | | `type` | `DOCUMENT`, `TEMPLATE` | Filter by envelope type | -| `status` | `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED` | Filter by status | -| `source` | `DOCUMENT`, `TEMPLATE`, `API` | Filter by creation source | +| `status` | `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, `CANCELLED` | Filter by status | +| `source` | `DOCUMENT`, `TEMPLATE`, `TEMPLATE_DIRECT_LINK` | Filter by creation source | | `folderId` | string | Filter by folder | ### Sorting @@ -798,10 +941,10 @@ async function getAllPendingDocuments() { }, ); - const { data, pagination } = await response.json(); + const { data, currentPage, totalPages } = await response.json(); documents.push(...data); - hasMore = page < pagination.totalPages; + hasMore = currentPage < totalPages; page++; } diff --git a/apps/docs/content/docs/developers/api/index.mdx b/apps/docs/content/docs/developers/api/index.mdx index 7f446c7ada..e8d7139eb6 100644 --- a/apps/docs/content/docs/developers/api/index.mdx +++ b/apps/docs/content/docs/developers/api/index.mdx @@ -5,6 +5,8 @@ description: Complete reference for the Documenso REST API. import { Callout } from 'fumadocs-ui/components/callout'; + + The guides below cover common API patterns but may not reflect the latest endpoints or parameters. For an always up-to-date reference, see the [OpenAPI Reference](https://openapi.documenso.com). diff --git a/apps/docs/content/docs/developers/api/meta.json b/apps/docs/content/docs/developers/api/meta.json index 7a19089dde..7906bbe979 100644 --- a/apps/docs/content/docs/developers/api/meta.json +++ b/apps/docs/content/docs/developers/api/meta.json @@ -8,6 +8,7 @@ "teams", "rate-limits", "versioning", + "migrate-to-envelopes", "developer-mode", "common-errors" ] diff --git a/apps/docs/content/docs/developers/api/migrate-to-envelopes.mdx b/apps/docs/content/docs/developers/api/migrate-to-envelopes.mdx new file mode 100644 index 0000000000..5a719e90c1 --- /dev/null +++ b/apps/docs/content/docs/developers/api/migrate-to-envelopes.mdx @@ -0,0 +1,249 @@ +--- +title: Migrating to Envelopes +description: Why Documenso unified documents and templates into envelopes, and how to migrate from the deprecated document and template create endpoints. +--- + +import { Accordion, Accordions } from 'fumadocs-ui/components/accordion'; +import { Callout } from 'fumadocs-ui/components/callout'; +import { Step, Steps } from 'fumadocs-ui/components/steps'; +import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; + +## Summary + +The following items have been deprecated and will be removed on the 1st of March 2027: + +- API V1 +- A subset of SDK/API V2 endpoints +- Legacy documents and templates +- EmbedCreateDocumentV1 +- EmbedCreateTemplateV1 +- EmbedUpdateDocumentV1 +- EmbedUpdateTemplateV1 + +The beta endpoint `/api/v2-beta` will also be removed. Use `/api/v2` instead, which is a drop-in replacement. + +Nothing breaks before 1st of March 2027, so you can migrate at your own pace. + +## What are legacy documents and templates + +These are documents and templates created by the following endpoints: + +- `POST /api/v2/document/create` +- `POST /api/v2/document/create/beta` +- `POST /api/v2/template/create` +- `POST /api/v2/template/create/beta` +- `POST /api/v1/documents` +- `POST /api/v1/templates` +- `POST /api/v1/templates/create-document` +- `POST /api/v1/templates/generate-document` + +## What replaces legacy documents and templates + +At the end of 2025 we introduced a unified system for documents and templates, called envelopes. + +We still reference documents and templates throughout the documentation and application to distinguish them, but internally they are envelopes. + +Moving to the envelope system gives you: + +- **Multiple PDFs in one envelope.** Send several documents to sign in a single request. +- **One API for documents and templates.** Learn one set of endpoints instead of two misaligned ones. +- **A better editor and signing experience** for you and your recipients. + +## How to migrate + +{/* prettier-ignore */} + + + ### Switch to the envelope endpoints + + Replace each deprecated endpoint with its `/api/v2/envelope/*` equivalent from the [mapping tables](#endpoint-mapping-reference) below. + + + ### Set the envelope `type` on create + + A single endpoint, `POST /api/v2/envelope/create`, can create both documents and templates. Set `type` to `DOCUMENT` or `TEMPLATE`. You can now upload more than one PDF using the `files` field. + + + ### Update how you store IDs + + Envelope IDs are **strings** (for example `envelope_abc123`), not numbers. Update any code that stores, parses, or compares IDs. + + + ### Test, then remove the old calls + + Verify the new flow against your account, then delete the deprecated calls. + + + +The main data differences are as follows: +- ID format changed from number to string (e.g. `42` to `envelope_abc123`) +- pageNumber becomes page +- pageX becomes positionX +- pageY becomes positionY + +See the [Documents API](/docs/developers/api/documents) and [Templates API](/docs/developers/api/templates) for the full envelope reference. + +### Deprecated V1 API Endpoints + +Full reference in the [V1 OpenAPI reference](https://openapi-v1.documenso.com). + +| Deprecated endpoint | Replacement | +| -------------------------------------------------------- | ----------------------------------------------------- | +| `GET /api/v1/documents` | `GET /api/v2/envelope` | +| `GET /api/v1/documents/{id}` | `GET /api/v2/envelope/{envelopeId}` | +| `POST /api/v1/documents` | `POST /api/v2/envelope/create` | +| `POST /api/v1/documents/{id}/send` | `POST /api/v2/envelope/distribute` | +| `POST /api/v1/documents/{id}/resend` | `POST /api/v2/envelope/redistribute` | +| `DELETE /api/v1/documents/{id}` | `POST /api/v2/envelope/delete` | +| `GET /api/v1/documents/{id}/download` | `GET /api/v2/envelope/item/{envelopeItemId}/download` | +| `POST /api/v1/documents/{id}/recipients` | `POST /api/v2/envelope/recipient/create-many` | +| `PATCH /api/v1/documents/{id}/recipients/{recipientId}` | `POST /api/v2/envelope/recipient/update-many` | +| `DELETE /api/v1/documents/{id}/recipients/{recipientId}` | `POST /api/v2/envelope/recipient/delete` | +| `POST /api/v1/documents/{id}/fields` | `POST /api/v2/envelope/field/create-many` | +| `PATCH /api/v1/documents/{id}/fields/{fieldId}` | `POST /api/v2/envelope/field/update-many` | +| `DELETE /api/v1/documents/{id}/fields/{fieldId}` | `POST /api/v2/envelope/field/delete` | +| `GET /api/v1/templates` | `GET /api/v2/envelope` (with `type=TEMPLATE`) | +| `GET /api/v1/templates/{id}` | `GET /api/v2/envelope/{envelopeId}` | +| `POST /api/v1/templates` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) | +| `DELETE /api/v1/templates/{id}` | `POST /api/v2/envelope/delete` | +| `POST /api/v1/templates/{templateId}/create-document` | `POST /api/v2/envelope/use` | +| `POST /api/v1/templates/{templateId}/generate-document` | `POST /api/v2/envelope/use` | + +### Deprecated V2 API Endpoints + +Full reference in the [V2 OpenAPI reference](https://openapi.documenso.com). + +#### Documents + +| Deprecated endpoint | Replacement | +| ------------------------------------------------- | ----------------------------------------------------- | +| `GET /api/v2/document` | `GET /api/v2/envelope` | +| `GET /api/v2/document/{documentId}` | `GET /api/v2/envelope/{envelopeId}` | +| `POST /api/v2/document/get-many` | `POST /api/v2/envelope/get-many` (body changes from `documentIds: number[]` to `ids: { type: "documentId"; ids: number[] }`) | +| `POST /api/v2/document/create` | `POST /api/v2/envelope/create` | +| `POST /api/v2/document/create/beta` | `POST /api/v2/envelope/create` | +| `POST /api/v2/document/update` | `POST /api/v2/envelope/update` | +| `POST /api/v2/document/delete` | `POST /api/v2/envelope/delete` | +| `POST /api/v2/document/duplicate` | `POST /api/v2/envelope/duplicate` | +| `POST /api/v2/document/distribute` | `POST /api/v2/envelope/distribute` | +| `POST /api/v2/document/redistribute` | `POST /api/v2/envelope/redistribute` | +| `GET /api/v2/document/attachment` | `GET /api/v2/envelope/attachment` | +| `POST /api/v2/document/attachment/create` | `POST /api/v2/envelope/attachment/create` | +| `POST /api/v2/document/attachment/update` | `POST /api/v2/envelope/attachment/update` | +| `POST /api/v2/document/attachment/delete` | `POST /api/v2/envelope/attachment/delete` | +| `GET /api/v2/document/{documentId}/download` | `GET /api/v2/envelope/item/{envelopeItemId}/download` | +| `GET /api/v2/document/{documentId}/download-beta` | `GET /api/v2/envelope/item/{envelopeItemId}/download` | + +#### Templates + +| Deprecated endpoint | Replacement | +| ------------------------------------- | ------------------------------------------------ | +| `GET /api/v2/template` | `GET /api/v2/envelope` (with `type=TEMPLATE`) | +| `GET /api/v2/template/{templateId}` | `GET /api/v2/envelope/{envelopeId}` | +| `POST /api/v2/template/get-many` | `POST /api/v2/envelope/get-many` (body changes from `templateIds: number[]` to `ids: { type: "templateId"; ids: number[] }`) | +| `POST /api/v2/template/create` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) | +| `POST /api/v2/template/create/beta` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) | +| `POST /api/v2/template/update` | `POST /api/v2/envelope/update` | +| `POST /api/v2/template/duplicate` | `POST /api/v2/envelope/duplicate` | +| `POST /api/v2/template/delete` | `POST /api/v2/envelope/delete` | +| `POST /api/v2/template/use` | `POST /api/v2/envelope/use` | +| `POST /api/v2/template/direct/create` | **Pending replacement** | +| `POST /api/v2/template/direct/delete` | **Pending replacement** | +| `POST /api/v2/template/direct/toggle` | **Pending replacement** | + +#### Document fields + +| Deprecated endpoint | Replacement | +| ----------------------------------------- | ----------------------------------------- | +| `GET /api/v2/document/field/{fieldId}` | `GET /api/v2/envelope/field/{fieldId}` | +| `POST /api/v2/document/field/create` | `POST /api/v2/envelope/field/create-many` | +| `POST /api/v2/document/field/create-many` | `POST /api/v2/envelope/field/create-many` | +| `POST /api/v2/document/field/update` | `POST /api/v2/envelope/field/update-many` | +| `POST /api/v2/document/field/update-many` | `POST /api/v2/envelope/field/update-many` | +| `POST /api/v2/document/field/delete` | `POST /api/v2/envelope/field/delete` | + +#### Template fields + +| Deprecated endpoint | Replacement | +| ----------------------------------------- | ----------------------------------------- | +| `GET /api/v2/template/field/{fieldId}` | `GET /api/v2/envelope/field/{fieldId}` | +| `POST /api/v2/template/field/create` | `POST /api/v2/envelope/field/create-many` | +| `POST /api/v2/template/field/create-many` | `POST /api/v2/envelope/field/create-many` | +| `POST /api/v2/template/field/update` | `POST /api/v2/envelope/field/update-many` | +| `POST /api/v2/template/field/update-many` | `POST /api/v2/envelope/field/update-many` | +| `POST /api/v2/template/field/delete` | `POST /api/v2/envelope/field/delete` | + +#### Document recipients + +| Deprecated endpoint | Replacement | +| ---------------------------------------------- | ---------------------------------------------- | +| `GET /api/v2/document/recipient/{recipientId}` | `GET /api/v2/envelope/recipient/{recipientId}` | +| `POST /api/v2/document/recipient/create` | `POST /api/v2/envelope/recipient/create-many` | +| `POST /api/v2/document/recipient/create-many` | `POST /api/v2/envelope/recipient/create-many` | +| `POST /api/v2/document/recipient/update` | `POST /api/v2/envelope/recipient/update-many` | +| `POST /api/v2/document/recipient/update-many` | `POST /api/v2/envelope/recipient/update-many` | +| `POST /api/v2/document/recipient/delete` | `POST /api/v2/envelope/recipient/delete` | + +#### Template recipients + +| Deprecated endpoint | Replacement | +| ---------------------------------------------- | ---------------------------------------------- | +| `GET /api/v2/template/recipient/{recipientId}` | `GET /api/v2/envelope/recipient/{recipientId}` | +| `POST /api/v2/template/recipient/create` | `POST /api/v2/envelope/recipient/create-many` | +| `POST /api/v2/template/recipient/create-many` | `POST /api/v2/envelope/recipient/create-many` | +| `POST /api/v2/template/recipient/update` | `POST /api/v2/envelope/recipient/update-many` | +| `POST /api/v2/template/recipient/update-many` | `POST /api/v2/envelope/recipient/update-many` | +| `POST /api/v2/template/recipient/delete` | `POST /api/v2/envelope/recipient/delete` | + +### Embedding components + +| Deprecated component | Replacement | +| ----------------------- | --------------------- | +| `EmbedCreateDocumentV1` | `EmbedCreateEnvelope` | +| `EmbedCreateTemplateV1` | `EmbedCreateEnvelope` | +| `EmbedUpdateDocumentV1` | `EmbedUpdateEnvelope` | +| `EmbedUpdateTemplateV1` | `EmbedUpdateEnvelope` | + +See the [embedding guide](/docs/developers/embedding) for the envelope components. + +## FAQ + + + + The deprecated V1 API, the V2 endpoints listed above, and the V1 embedding components are removed. + Requests to them will fail, so migrate to the envelope API before that date. + + + Yes. Documents and templates you already created remain in your account and continue to work. They will automatically be converted to envelopes. Only + the deprecated endpoints you call are going away. Your data is not deleted. + + + No. Authentication is unchanged. The same API token works for the envelope endpoints under + `https://app.documenso.com/api/v2`. + + + Both are envelopes, distinguished by a `type` field of `DOCUMENT` or `TEMPLATE`. They share the same + endpoints, recipients, fields, and attachments. + + + The function calls to the legacy endpoints will break on the 1st of March 2027. Update to the latest SDK version and switch to its envelope methods. + The deprecated document and template methods map to the envelope endpoints in the tables above. + + + Reach out to [support@documenso.com](mailto:support@documenso.com) with your use case and we will + help you plan the migration. + + + +## Getting help + +- [V2 OpenAPI reference](https://openapi.documenso.com): the up-to-date envelope API. +- [V1 OpenAPI reference](https://openapi-v1.documenso.com): the deprecated V1 API. +- [support@documenso.com](mailto:support@documenso.com): migration questions and extensions. + +## See also + +- [Documents API](/docs/developers/api/documents): create and manage envelopes +- [Templates API](/docs/developers/api/templates): work with templates and direct links +- [Fields API](/docs/developers/api/fields) and [Recipients API](/docs/developers/api/recipients) +- [API Versioning](/docs/developers/api/versioning): how Documenso versions the public API diff --git a/apps/docs/content/docs/developers/api/rate-limits.mdx b/apps/docs/content/docs/developers/api/rate-limits.mdx index 95b0a68fe3..878db97b6e 100644 --- a/apps/docs/content/docs/developers/api/rate-limits.mdx +++ b/apps/docs/content/docs/developers/api/rate-limits.mdx @@ -11,6 +11,12 @@ Documenso enforces rate limits on all API endpoints to ensure service stability. ## HTTP Rate Limits +The rate limit applies to: + +- `/api/v1/*` +- `/api/v2/*` +- `/api/v2-beta/*` + **Limit:** 1000 requests per minute per IP address **Response:** 429 Too Many Requests @@ -19,7 +25,7 @@ Documenso enforces rate limits on all API endpoints to ensure service stability. this value, in which case you can be rate-limited before reaching the global limit. -### Rate Limit Response +### Global per-IP 429 Response ```json { @@ -27,10 +33,22 @@ Documenso enforces rate limits on all API endpoints to ensure service stability. } ``` - - No rate limit headers are currently provided. When you receive a 429 response, wait at least 60 - seconds before retrying. - +### Rate Limit Headers + +Responses from `/api/v1/*`, `/api/v2/*`, and `/api/v2-beta/*` include these headers. The only +exception is CORS preflight (`OPTIONS`) requests, which are answered before the rate limiter runs +and carry no rate limit headers: + +| Header | Description | +| ----------------------- | ---------------------------------------------------------------------- | +| `X-RateLimit-Limit` | Maximum requests allowed in the current global window | +| `X-RateLimit-Remaining` | Requests remaining in the current global window | +| `X-RateLimit-Reset` | End of the current global window, as a Unix epoch timestamp in seconds | + +A 429 response from a windowed limiter also includes `Retry-After`, in seconds, with a minimum +value of `1`. The global API limit uses fixed, epoch-aligned one-minute buckets, so the actual wait +until the next window is between 1 and 60 seconds. Honor `Retry-After` exactly instead of sleeping +for a fixed 60 seconds. See the [Retry-After handling example](/docs/developers/examples/common-workflows#error-handling-patterns). ## Resource Limits @@ -44,24 +62,55 @@ Beyond HTTP rate limits, your account has usage limits based on your subscriptio | Total Recipients | 10 | Unlimited | Unlimited | Unlimited | | Direct Templates | 3 | Unlimited | Unlimited | Unlimited | -### Error Response +### Organisation Limit 429 Responses + +Organisation windowed limits and organisation monthly quotas produce 429 responses whose body +shape depends on the API version, and neither matches the global per-IP limiter's +`{ "error": "..." }` body. + +On `/api/v1/*`, the body contains only a message: -When you exceed a resource limit: +```json +{ + "message": "Too many requests, please try again later. Contact support if you require higher limits." +} +``` + +On `/api/v2/*` and `/api/v2-beta/*`, the body is a structured error object: ```json { - "error": "You have reached your document limit for this month. Please upgrade your plan.", - "code": "LIMIT_EXCEEDED", - "statusCode": 400 + "message": "Too many requests, please try again later. Contact support if you require higher limits.", + "code": "TOO_MANY_REQUESTS", + "data": { + "code": "TOO_MANY_REQUESTS", + "httpStatus": 429, + "appError": { + "code": "TOO_MANY_REQUESTS", + "message": "Too many requests, please try again later. Contact support if you require higher limits." + } + } } ``` +Organisation windowed limit responses include the `X-RateLimit-*` headers and `Retry-After` for +their own window. Monthly quota responses carry no quota-specific rate limit headers or +`Retry-After` because the quota is not a time window; rely on the status code and message instead. + ## Error Codes -| Code | Status | Description | -| ------------------- | ------ | ----------------------------- | -| `TOO_MANY_REQUESTS` | 429 | HTTP rate limit exceeded | -| `LIMIT_EXCEEDED` | 400 | Resource usage limit exceeded | +| Code | Status | Description | +| ------------------- | ------ | ------------------------------------------------------------------ | +| `TOO_MANY_REQUESTS` | 429 | Global per-IP, organisation windowed, or monthly quota exceeded | +| `LIMIT_EXCEEDED` | 400 | Resource usage limit exceeded | + +There are three sources of `TOO_MANY_REQUESTS` responses: + +1. The global per-IP limit, returning the `{ "error": "..." }` body shown above. +2. Organisation windowed rate limits for the `api`, `document`, and `email` counters. +3. Organisation monthly quotas for the same three counters. Every authenticated API request + consumes the `api` counter, so any endpoint can return this 429 once the monthly API quota is + exhausted — not just envelope-related ones. --- diff --git a/apps/docs/content/docs/developers/api/teams.mdx b/apps/docs/content/docs/developers/api/teams.mdx index 99d708b41f..0d869c56cb 100644 --- a/apps/docs/content/docs/developers/api/teams.mdx +++ b/apps/docs/content/docs/developers/api/teams.mdx @@ -95,7 +95,7 @@ Documents created with a team token belong to that team: ```bash curl -X POST "https://app.documenso.com/api/v2/envelope/create" \ - -H "Authorization: api_team_xxxxxxxxxxxxxxxx" \ + -H "Authorization: api_xxxxxxxxxxxxxxxx" \ -H "Content-Type: multipart/form-data" \ -F 'payload={ "type": "DOCUMENT", @@ -157,11 +157,11 @@ Retrieve all documents belonging to the team: ```bash # List all team documents curl -X GET "https://app.documenso.com/api/v2/envelope" \ - -H "Authorization: api_team_xxxxxxxxxxxxxxxx" + -H "Authorization: api_xxxxxxxxxxxxxxxx" # Filter by status curl -X GET "https://app.documenso.com/api/v2/envelope?status=PENDING" \ - -H "Authorization: api_team_xxxxxxxxxxxxxxxx" + -H "Authorization: api_xxxxxxxxxxxxxxxx" ```` @@ -191,7 +191,7 @@ Templates created with a team token are shared across the team. ```bash curl -X POST "https://app.documenso.com/api/v2/template/create" \ - -H "Authorization: api_team_xxxxxxxxxxxxxxxx" \ + -H "Authorization: api_xxxxxxxxxxxxxxxx" \ -H "Content-Type: multipart/form-data" \ -F 'payload={ "title": "NDA Template", @@ -269,7 +269,7 @@ console.log('Created team template:', template.id); ```bash curl -X GET "https://app.documenso.com/api/v2/template" \ - -H "Authorization: api_team_xxxxxxxxxxxxxxxx" + -H "Authorization: api_xxxxxxxxxxxxxxxx" ```` diff --git a/apps/docs/content/docs/developers/api/templates.mdx b/apps/docs/content/docs/developers/api/templates.mdx index 8f5c5b6672..b3f52e1463 100644 --- a/apps/docs/content/docs/developers/api/templates.mdx +++ b/apps/docs/content/docs/developers/api/templates.mdx @@ -6,6 +6,8 @@ description: Create documents from reusable templates via API. import { Callout } from 'fumadocs-ui/components/callout'; import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; + + This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference, see the [OpenAPI Reference](https://openapi.documenso.com). diff --git a/apps/docs/content/docs/developers/api/versioning.mdx b/apps/docs/content/docs/developers/api/versioning.mdx index c137a869ac..9e94350341 100644 --- a/apps/docs/content/docs/developers/api/versioning.mdx +++ b/apps/docs/content/docs/developers/api/versioning.mdx @@ -5,6 +5,8 @@ description: Versioning information for the Documenso public API. import { Callout } from 'fumadocs-ui/components/callout'; + + ## Overview Documenso uses API versioning to manage changes to the public API. This allows us to introduce new features, fix bugs, and make other changes without breaking existing integrations. @@ -19,7 +21,16 @@ Also, we may deprecate certain features or endpoints in the API. When we depreca --- +## Documents, Templates, and Envelopes + +Documenso has unified documents and templates into a single resource called an **envelope**. New integrations should create documents and templates through the `/envelope/*` endpoints. The `POST /document/create` and `POST /template/create` endpoints (including their `/beta` variants) are deprecated in favor of `POST /envelope/create`. + +See [Migrating to the Envelope API](/docs/developers/api/migrate-to-envelopes) for the rationale and step-by-step migration examples. + +--- + ## See Also +- [Migrating to the Envelope API](/docs/developers/api/migrate-to-envelopes) - Move from the document and template create endpoints - [Authentication](/docs/developers/getting-started/authentication) - API authentication guide - [Rate Limits](/docs/developers/api/rate-limits) - API rate limit details diff --git a/apps/docs/content/docs/developers/examples/common-workflows.mdx b/apps/docs/content/docs/developers/examples/common-workflows.mdx index e0447d7c48..5cac47f796 100644 --- a/apps/docs/content/docs/developers/examples/common-workflows.mdx +++ b/apps/docs/content/docs/developers/examples/common-workflows.mdx @@ -8,6 +8,8 @@ import { Callout } from 'fumadocs-ui/components/callout'; import { Step, Steps } from 'fumadocs-ui/components/steps'; import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; + + ## Workflow 1: Send a Document for Signature The most common workflow: upload a PDF, add recipients with signature fields, and send for signing. @@ -49,6 +51,7 @@ async function createAndSendDocument( pdfBuffer: Buffer, filename: string, title: string, + externalId: string, recipients: Recipient[], ): Promise { const recipientPayload = recipients.map((recipient, index) => ({ @@ -87,6 +90,7 @@ async function createAndSendDocument( JSON.stringify({ type: 'DOCUMENT', title, + externalId, recipients: recipientPayload, meta: { subject: `Please sign: ${title}`, @@ -143,6 +147,7 @@ const result = await createAndSendDocument( pdfBuffer, 'contract.pdf', 'Service Agreement', + 'nda-contract-ndac214', [ { email: 'client@example.com', name: 'John Smith', role: 'SIGNER' }, { email: 'manager@company.com', name: 'Jane Doe', role: 'SIGNER' }, @@ -170,6 +175,7 @@ ENVELOPE_RESPONSE=$(curl -s -X POST "${BASE_URL}/envelope/create" \ -F 'payload={ "type": "DOCUMENT", "title": "Service Agreement", + "externalId": "nda-contract-ndac214", "recipients": [ { "email": "client@example.com", @@ -239,6 +245,8 @@ echo $DISTRIBUTE_RESPONSE | jq '.recipients[] | {email, signingUrl}' +`externalId` is your application's own reference for this document, such as an invoice number or a database key. Documenso stores it on the envelope and repeats it in every webhook as `payload.externalId`, so your handler can match the event to your record without keeping a lookup table of Documenso IDs. To react when everyone has signed, see [Workflow 4](#workflow-4-wait-for-completion-with-webhooks). To fetch the finished PDF, see [Workflow 5](#workflow-5-download-signed-documents). + --- ## Workflow 2: Create Document from Template with Custom Data @@ -998,9 +1006,12 @@ async function fetchWithRetry( // Retry on rate limit if (response.status === 429) { const retryAfter = response.headers.get('Retry-After'); - const delay = retryAfter ? parseInt(retryAfter) * 1000 : baseDelayMs * Math.pow(2, attempt); + // Honor Retry-After exactly; the cap only applies to the exponential fallback. + const delay = retryAfter + ? parseInt(retryAfter) * 1000 + : Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs); console.log(`Rate limited, waiting ${delay}ms...`); - await new Promise((resolve) => setTimeout(resolve, Math.min(delay, maxDelayMs))); + await new Promise((resolve) => setTimeout(resolve, delay)); continue; } diff --git a/apps/docs/content/docs/developers/examples/index.mdx b/apps/docs/content/docs/developers/examples/index.mdx index bdbcdb0b59..aab7191cc3 100644 --- a/apps/docs/content/docs/developers/examples/index.mdx +++ b/apps/docs/content/docs/developers/examples/index.mdx @@ -3,6 +3,8 @@ title: Examples description: Common integration patterns and end-to-end workflows. --- + + + ## Prerequisites - A Documenso account (cloud or self-hosted) diff --git a/apps/docs/content/docs/developers/getting-started/first-api-call.mdx b/apps/docs/content/docs/developers/getting-started/first-api-call.mdx index 665a90afd5..e87b854388 100644 --- a/apps/docs/content/docs/developers/getting-started/first-api-call.mdx +++ b/apps/docs/content/docs/developers/getting-started/first-api-call.mdx @@ -7,6 +7,8 @@ import { Callout } from 'fumadocs-ui/components/callout'; import { Step, Steps } from 'fumadocs-ui/components/steps'; import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; + + ## Prerequisites Before starting, you need: diff --git a/apps/docs/content/docs/developers/getting-started/index.mdx b/apps/docs/content/docs/developers/getting-started/index.mdx index d2070f2b54..f38145d7e0 100644 --- a/apps/docs/content/docs/developers/getting-started/index.mdx +++ b/apps/docs/content/docs/developers/getting-started/index.mdx @@ -3,6 +3,8 @@ title: Getting Started description: Get your API key and make your first API call. --- + + + ## Getting Started diff --git a/apps/docs/content/docs/developers/webhooks/events.mdx b/apps/docs/content/docs/developers/webhooks/events.mdx index 9f63f78ac8..5bfaa7a429 100644 --- a/apps/docs/content/docs/developers/webhooks/events.mdx +++ b/apps/docs/content/docs/developers/webhooks/events.mdx @@ -33,13 +33,14 @@ All webhook events share a common structure: | Field | Type | Description | | ---------------- | --------- | ------------------------------------------------------ | -| `id` | number | Document or template ID | +| `id` | number | Legacy numeric v1 document or template ID | +| `envelopeId` | string | Canonical v2 identifier (`envelope_` + 16 characters) | | `externalId` | string? | External identifier for integration | | `userId` | number | Owner's user ID | | `authOptions` | object? | Document-level authentication options | | `formValues` | object? | PDF form values associated with the document | | `title` | string | Document or template title | -| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED` | +| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, `CANCELLED` | | `visibility` | string | Document visibility setting | | `createdAt` | datetime | Document creation timestamp | | `updatedAt` | datetime | Last modification timestamp | @@ -47,8 +48,8 @@ All webhook events share a common structure: | `deletedAt` | datetime? | Deletion timestamp | | `teamId` | number? | Team ID if document belongs to a team | | `templateId` | number? | Template ID if created from a template | -| `source` | string | Source: `DOCUMENT` or `TEMPLATE` | -| `documentMeta` | object | Document metadata (subject, message, signing options) | +| `source` | string | Source: `DOCUMENT`, `TEMPLATE`, or `TEMPLATE_DIRECT_LINK` | +| `documentMeta` | object? | Nullable document metadata (subject, message, signing options) | | `recipients` | array | List of recipient objects | | `Recipient` | array | List of recipient objects (legacy, same as recipients) | @@ -60,7 +61,6 @@ All webhook events share a common structure: | `subject` | string? | Email subject line | | `message` | string? | Email message body | | `timezone` | string | Timezone for date display | -| `password` | string? | Document access password (if set) | | `dateFormat` | string | Date format string | | `redirectUrl` | string? | URL to redirect after signing | | `signingOrder` | string | `PARALLEL` or `SEQUENTIAL` | @@ -77,8 +77,9 @@ All webhook events share a common structure: | Field | Type | Description | | ---------------------- | --------- | ------------------------------------------ | | `id` | number | Recipient ID | -| `documentId` | number? | Parent document ID | -| `templateId` | number? | Template ID if created from a template | +| `envelopeId` | string | Canonical parent envelope ID | +| `documentId` | number? | Legacy parent document ID; null for templates | +| `templateId` | number? | Legacy parent template ID; null for documents | | `email` | string | Recipient email address | | `name` | string | Recipient name | | `token` | string | Unique signing token | @@ -94,6 +95,8 @@ All webhook events share a common structure: | `sendStatus` | string | `NOT_SENT` or `SENT` | | `rejectionReason` | string? | Reason if recipient rejected | +Use `recipient.envelopeId` as the reliable parent link. The legacy `documentId` and `templateId` fields depend on the parent envelope type, so one of them is always null. + --- ## Document Lifecycle Events @@ -111,6 +114,7 @@ Triggered when a new document is created. "event": "DOCUMENT_CREATED", "payload": { "id": 10, + "envelopeId": "envelope_abcdefhiklmnorst", "externalId": null, "userId": 1, "authOptions": null, @@ -129,9 +133,8 @@ Triggered when a new document is created. "id": "doc_meta_123", "subject": "Please sign this document", "message": "Hello, please review and sign this document.", - "timezone": "UTC", - "password": null, - "dateFormat": "MM/DD/YYYY", + "timezone": "Etc/UTC", + "dateFormat": "yyyy-MM-dd hh:mm a", "redirectUrl": null, "signingOrder": "PARALLEL", "allowDictateNextSigner": false, @@ -145,6 +148,7 @@ Triggered when a new document is created. "recipients": [ { "id": 52, + "envelopeId": "envelope_abcdefhiklmnorst", "documentId": 10, "templateId": null, "email": "signer@example.com", @@ -166,6 +170,7 @@ Triggered when a new document is created. "Recipient": [ { "id": 52, + "envelopeId": "envelope_abcdefhiklmnorst", "documentId": 10, "templateId": null, "email": "signer@example.com", @@ -203,6 +208,7 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT" "event": "DOCUMENT_SENT", "payload": { "id": 10, + "envelopeId": "envelope_abcdefhiklmnorst", "externalId": null, "userId": 1, "authOptions": null, @@ -221,9 +227,8 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT" "id": "doc_meta_123", "subject": "Please sign this document", "message": "Hello, please review and sign this document.", - "timezone": "UTC", - "password": null, - "dateFormat": "MM/DD/YYYY", + "timezone": "Etc/UTC", + "dateFormat": "yyyy-MM-dd hh:mm a", "redirectUrl": null, "signingOrder": "PARALLEL", "allowDictateNextSigner": false, @@ -237,6 +242,7 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT" "recipients": [ { "id": 52, + "envelopeId": "envelope_abcdefhiklmnorst", "documentId": 10, "templateId": null, "email": "signer@example.com", @@ -258,6 +264,7 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT" "Recipient": [ { "id": 52, + "envelopeId": "envelope_abcdefhiklmnorst", "documentId": 10, "templateId": null, "email": "signer@example.com", @@ -295,12 +302,14 @@ The recipient's `readStatus` changes to `OPENED`. "event": "DOCUMENT_OPENED", "payload": { "id": 10, + "envelopeId": "envelope_abcdefhiklmnorst", "status": "PENDING", "title": "contract.pdf", "source": "DOCUMENT", "recipients": [ { "id": 52, + "envelopeId": "envelope_abcdefhiklmnorst", "email": "signer@example.com", "name": "John Doe", "role": "SIGNER", @@ -328,6 +337,7 @@ The recipient's `signingStatus` changes to `SIGNED` and `signedAt` is populated. "event": "DOCUMENT_SIGNED", "payload": { "id": 10, + "envelopeId": "envelope_abcdefhiklmnorst", "status": "COMPLETED", "title": "contract.pdf", "source": "DOCUMENT", @@ -335,6 +345,7 @@ The recipient's `signingStatus` changes to `SIGNED` and `signedAt` is populated. "recipients": [ { "id": 51, + "envelopeId": "envelope_abcdefhiklmnorst", "email": "signer@example.com", "name": "John Doe", "role": "SIGNER", @@ -361,12 +372,14 @@ Triggered when an individual recipient completes their required action (signing, "event": "DOCUMENT_RECIPIENT_COMPLETED", "payload": { "id": 10, + "envelopeId": "envelope_abcdefhiklmnorst", "status": "PENDING", "title": "contract.pdf", "source": "DOCUMENT", "recipients": [ { "id": 52, + "envelopeId": "envelope_abcdefhiklmnorst", "email": "signer@example.com", "name": "John Doe", "role": "SIGNER", @@ -395,6 +408,7 @@ The document status changes to `COMPLETED` and `completedAt` is set. "event": "DOCUMENT_COMPLETED", "payload": { "id": 10, + "envelopeId": "envelope_abcdefhiklmnorst", "externalId": null, "userId": 1, "authOptions": null, @@ -413,9 +427,8 @@ The document status changes to `COMPLETED` and `completedAt` is set. "id": "doc_meta_123", "subject": "Please sign this document", "message": "Hello, please review and sign this document.", - "timezone": "UTC", - "password": null, - "dateFormat": "MM/DD/YYYY", + "timezone": "Etc/UTC", + "dateFormat": "yyyy-MM-dd hh:mm a", "redirectUrl": null, "signingOrder": "PARALLEL", "allowDictateNextSigner": false, @@ -429,6 +442,7 @@ The document status changes to `COMPLETED` and `completedAt` is set. "recipients": [ { "id": 50, + "envelopeId": "envelope_abcdefhiklmnorst", "documentId": 10, "templateId": null, "email": "reviewer@example.com", @@ -451,6 +465,7 @@ The document status changes to `COMPLETED` and `completedAt` is set. }, { "id": 51, + "envelopeId": "envelope_abcdefhiklmnorst", "documentId": 10, "templateId": null, "email": "signer@example.com", @@ -475,6 +490,7 @@ The document status changes to `COMPLETED` and `completedAt` is set. "Recipient": [ { "id": 50, + "envelopeId": "envelope_abcdefhiklmnorst", "documentId": 10, "templateId": null, "email": "reviewer@example.com", @@ -497,6 +513,7 @@ The document status changes to `COMPLETED` and `completedAt` is set. }, { "id": 51, + "envelopeId": "envelope_abcdefhiklmnorst", "documentId": 10, "templateId": null, "email": "signer@example.com", @@ -537,12 +554,14 @@ The recipient's `signingStatus` changes to `REJECTED` and `rejectionReason` cont "event": "DOCUMENT_REJECTED", "payload": { "id": 10, + "envelopeId": "envelope_abcdefhiklmnorst", "status": "PENDING", "title": "contract.pdf", "source": "DOCUMENT", "recipients": [ { "id": 52, + "envelopeId": "envelope_abcdefhiklmnorst", "email": "signer@example.com", "name": "John Doe", "role": "SIGNER", @@ -561,7 +580,7 @@ The recipient's `signingStatus` changes to `REJECTED` and `rejectionReason` cont ### `document.cancelled` -Triggered when the document owner or a team member deletes a document. Draft and pending documents are hard-deleted, while completed documents are soft-deleted. +Triggered when a pending document is explicitly cancelled with `POST /envelope/cancel`, or when a document owner or team member deletes a document. Deleting a draft or pending document hard-deletes it, while deleting a completed document soft-deletes it. This event is **not** triggered when a recipient hides a document from their inbox. @@ -572,6 +591,7 @@ This event is **not** triggered when a recipient hides a document from their inb "event": "DOCUMENT_CANCELLED", "payload": { "id": 7, + "envelopeId": "envelope_abcdefhiklmnorst", "externalId": null, "userId": 3, "authOptions": null, @@ -591,7 +611,6 @@ This event is **not** triggered when a recipient hides a document from their inb "subject": "", "message": "", "timezone": "Etc/UTC", - "password": null, "dateFormat": "yyyy-MM-dd hh:mm a", "redirectUrl": "", "signingOrder": "PARALLEL", @@ -606,6 +625,7 @@ This event is **not** triggered when a recipient hides a document from their inb "recipients": [ { "id": 7, + "envelopeId": "envelope_abcdefhiklmnorst", "documentId": 7, "templateId": null, "email": "signer@example.com", @@ -627,6 +647,7 @@ This event is **not** triggered when a recipient hides a document from their inb "Recipient": [ { "id": 7, + "envelopeId": "envelope_abcdefhiklmnorst", "documentId": 7, "templateId": null, "email": "signer@example.com", @@ -651,6 +672,45 @@ This event is **not** triggered when a recipient hides a document from their inb } ``` +### `recipient.expired` + +Triggered when a recipient's signing deadline passes on a pending document before they sign or reject it. + +**Event name:** `RECIPIENT_EXPIRED` + +The recipient's `expiresAt` contains the signing deadline, and `expirationNotifiedAt` is set when the expiration is processed. + +```json +{ + "event": "RECIPIENT_EXPIRED", + "payload": { + "id": 10, + "envelopeId": "envelope_abcdefhiklmnorst", + "status": "PENDING", + "title": "contract.pdf", + "source": "DOCUMENT", + "recipients": [ + { + "id": 52, + "envelopeId": "envelope_abcdefhiklmnorst", + "documentId": 10, + "templateId": null, + "email": "signer@example.com", + "name": "John Doe", + "role": "SIGNER", + "expiresAt": "2024-04-22T11:51:00.000Z", + "expirationNotifiedAt": "2024-04-22T11:52:00.000Z", + "readStatus": "OPENED", + "signingStatus": "NOT_SIGNED", + "sendStatus": "SENT" + } + ] + }, + "createdAt": "2024-04-22T11:52:00.000Z", + "webhookEndpoint": "https://your-endpoint.com/webhook" +} +``` + ### `document.reminder.sent` Triggered when a reminder email is sent to a recipient who has not yet completed their action. @@ -662,12 +722,14 @@ Triggered when a reminder email is sent to a recipient who has not yet completed "event": "DOCUMENT_REMINDER_SENT", "payload": { "id": 10, + "envelopeId": "envelope_abcdefhiklmnorst", "status": "PENDING", "title": "contract.pdf", "source": "DOCUMENT", "recipients": [ { "id": 52, + "envelopeId": "envelope_abcdefhiklmnorst", "email": "signer@example.com", "name": "John Doe", "role": "SIGNER", @@ -686,7 +748,7 @@ Triggered when a reminder email is sent to a recipient who has not yet completed ## Template Events -Template events track changes to reusable document templates. Template payloads use the same structure as document payloads, with `source` set to `TEMPLATE` and `templateId` populated. +Template events track changes to reusable document templates. Template payloads use the same structure as document payloads. For `TEMPLATE_CREATED`, `TEMPLATE_UPDATED`, and `TEMPLATE_DELETED` the template's own legacy numeric ID is in `id` and `templateId` is `null`. Only `TEMPLATE_USED` — whose payload describes the new document envelope created from the template — carries the originating template's legacy ID in `templateId`, with `source` set to `TEMPLATE`. ### `template.created` @@ -699,9 +761,10 @@ Triggered when a new template is created. "event": "TEMPLATE_CREATED", "payload": { "id": 10, + "envelopeId": "envelope_abcdefhiklmnorst", "title": "My Template", "status": "DRAFT", - "templateId": 10, + "templateId": null, "source": "TEMPLATE", "recipients": [] }, @@ -721,9 +784,10 @@ Triggered when a template's settings, recipients, or fields are modified. "event": "TEMPLATE_UPDATED", "payload": { "id": 10, + "envelopeId": "envelope_abcdefhiklmnorst", "title": "My Updated Template", "status": "DRAFT", - "templateId": 10, + "templateId": null, "source": "TEMPLATE", "recipients": [] }, @@ -743,9 +807,10 @@ Triggered when a template is deleted. "event": "TEMPLATE_DELETED", "payload": { "id": 10, + "envelopeId": "envelope_abcdefhiklmnorst", "title": "Deleted Template", "status": "DRAFT", - "templateId": 10, + "templateId": null, "source": "TEMPLATE", "recipients": [] }, @@ -765,6 +830,7 @@ Triggered when a document is created from a template. This event fires alongside "event": "TEMPLATE_USED", "payload": { "id": 10, + "envelopeId": "envelope_abcdefhiklmnorst", "title": "Document from Template", "status": "DRAFT", "templateId": 10, @@ -791,7 +857,8 @@ Triggered when a document is created from a template. This event fires alongside | `DOCUMENT_RECIPIENT_COMPLETED` | Recipient completes their action | Recipient `signingStatus: "SIGNED"`, `signedAt` set | | `DOCUMENT_COMPLETED` | All recipients complete actions | `status: "COMPLETED"`, `completedAt` set | | `DOCUMENT_REJECTED` | Recipient rejects document | Recipient `signingStatus: "REJECTED"`, `rejectionReason` set | -| `DOCUMENT_CANCELLED` | Owner or team member deletes document | Document cancelled or deleted | +| `DOCUMENT_CANCELLED` | Pending document explicitly cancelled, or document deleted | `status: "CANCELLED"` after explicit cancellation; deletion may remove or soft-delete the document | +| `RECIPIENT_EXPIRED` | Recipient signing deadline passes | Recipient `expiresAt` passed, `expirationNotifiedAt` set | | `DOCUMENT_REMINDER_SENT` | Reminder email sent to recipient | No status changes | ### Template Events @@ -821,7 +888,7 @@ When processing webhook events: **Process idempotently** — Webhooks may be retried, so handle duplicate events - **Respond quickly** — Return a 200 status code within 30 seconds + **Respond quickly** — Return a `2xx` status code within 10 seconds diff --git a/apps/docs/content/docs/developers/webhooks/index.mdx b/apps/docs/content/docs/developers/webhooks/index.mdx index 14bb89123a..8c27eccbd2 100644 --- a/apps/docs/content/docs/developers/webhooks/index.mdx +++ b/apps/docs/content/docs/developers/webhooks/index.mdx @@ -9,7 +9,7 @@ description: Receive real-time notifications for document and template events. 2. When an event occurs, Documenso sends an HTTP POST to your URL 3. Your application processes the event and responds with 200 OK -Documenso supports webhook events for the full document lifecycle (created, sent, opened, signed, completed, rejected, cancelled) as well as template events (created, updated, deleted, used). +Documenso supports webhook events for the full document lifecycle (created, sent, opened, signed, completed, rejected, cancelled), recipient-level events (recipient completed, reminder sent, recipient expired), and template events (created, updated, deleted, used). --- @@ -42,12 +42,14 @@ Documenso supports webhook events for the full document lifecycle (created, sent "event": "DOCUMENT_COMPLETED", "payload": { "id": 123, + "envelopeId": "envelope_abcdefhiklmnorst", "title": "Contract", "status": "COMPLETED", "completedAt": "2024-01-15T10:30:00.000Z", "recipients": [ { "id": 1, + "envelopeId": "envelope_abcdefhiklmnorst", "email": "signer@example.com", "signingStatus": "SIGNED" } @@ -58,6 +60,8 @@ Documenso supports webhook events for the full document lifecycle (created, sent } ``` +`payload.id` is the legacy numeric v1 ID. Use `payload.envelopeId` as the canonical v2 identifier. Each recipient repeats `envelopeId` as the reliable parent link because the legacy `documentId` and `templateId` fields depend on the parent envelope type, leaving one of them null. + --- ## See Also diff --git a/apps/docs/content/docs/developers/webhooks/setup.mdx b/apps/docs/content/docs/developers/webhooks/setup.mdx index 1725bec05f..88fda57ea8 100644 --- a/apps/docs/content/docs/developers/webhooks/setup.mdx +++ b/apps/docs/content/docs/developers/webhooks/setup.mdx @@ -148,7 +148,7 @@ func main() { - Always respond with a `200 OK` status within 30 seconds. Documenso will retry failed deliveries. + Always respond with a `2xx` status within 10 seconds. Documenso will retry failed deliveries according to the configured background-job provider. ## Configuring Webhooks in Documenso via the Dashboard @@ -184,7 +184,7 @@ Fill in the following fields: | Field | Description | | ----- | ----------- | -| **Webhook URL** | The HTTPS endpoint that will receive webhook events | +| **Webhook URL** | The HTTP or HTTPS endpoint that will receive webhook events | | **Events** | Select which events should trigger this webhook | | **Secret** (optional) | A secret key used to sign the payload for verification | @@ -202,12 +202,21 @@ Your webhook endpoint must meet these requirements: | Requirement | Details | | ----------- | ------- | -| **Protocol** | HTTPS required (HTTP not allowed in production) | -| **Response** | Must return `2xx` status code within 30 seconds | +| **Protocol** | HTTP and HTTPS are accepted; use HTTPS in production | +| **Response** | Must return a `2xx` status code within 10 seconds | | **Method** | Must accept HTTP POST requests | | **Content-Type** | Must accept `application/json` payloads | | **Availability** | Must be publicly accessible from the internet | + + Documenso performs a best-effort check that rejects webhook URLs which use or resolve to private + or loopback addresses. This is not a complete SSRF mitigation — it does not cover DNS rebinding + and fails open on DNS lookup errors or timeouts — so self-hosted deployments should still enforce + network-level egress rules. Self-hosters that need to deliver to a hostname resolving to a + private address can add that hostname to the comma-separated + `NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS` environment variable. + + For local development, use a tunneling service like [ngrok](https://ngrok.com) or [localtunnel](https://localtunnel.me) to expose your local server. @@ -225,7 +234,8 @@ When creating a webhook, you can subscribe to one or more events: | `DOCUMENT_RECIPIENT_COMPLETED` | A recipient completes their required action | | `DOCUMENT_COMPLETED` | All recipients have completed their actions | | `DOCUMENT_REJECTED` | A recipient rejects the document | -| `DOCUMENT_CANCELLED` | The document owner deletes the document | +| `DOCUMENT_CANCELLED` | A pending document is explicitly cancelled or a document owner deletes it | +| `RECIPIENT_EXPIRED` | A recipient's signing deadline passes before they sign or reject | | `DOCUMENT_REMINDER_SENT` | A reminder email is sent to a recipient | | `TEMPLATE_CREATED` | A new template is created | | `TEMPLATE_UPDATED` | A template is modified | @@ -294,6 +304,7 @@ Each webhook call shows the following details: - Timestamp - Response code - Request and response bodies +- Response headers Click any call to see full details including headers and response data. @@ -318,17 +329,17 @@ Documenso will attempt to deliver the same payload again ## Retry Policy -When a webhook delivery fails (non-2xx response or timeout), Documenso automatically retries with exponential backoff: +A delivery fails when the endpoint returns a non-`2xx` response, the 10-second timeout expires, or the request fails. Redirects are not followed, so `3xx` responses also fail. Network and SSRF-blocked requests are recorded with response code `0`. + +For self-hosted deployments, retries are handled by the background-job provider selected with `NEXT_PRIVATE_JOBS_PROVIDER`: -| Attempt | Delay | -| ------- | ----- | -| 1 | Immediate | -| 2 | 1 minute | -| 3 | 5 minutes | -| 4 | 30 minutes | -| 5 | 2 hours | +| Provider | Total attempts | Retry timing | +| -------- | -------------- | ------------ | +| Local (default) | 4 | Back-to-back, with no backoff | +| BullMQ | 3 | Exponential backoff starting at 1 second | +| Inngest | 5 | Inngest platform backoff | -After 5 failed attempts, the webhook is marked as failed and no further automatic retries occur. You can manually resend failed webhooks from the dashboard. +Only the individual delivery (`WebhookCall`) record is marked as failed. Documenso does not automatically disable the webhook or apply a circuit breaker, so future matching events continue to be delivered. After automatic attempts are exhausted, you can manually resend a failed delivery from the dashboard. If your endpoint consistently fails, consider reviewing your server logs and ensuring your endpoint meets all [URL requirements](#webhook-url-requirements). diff --git a/apps/docs/content/docs/developers/webhooks/verification.mdx b/apps/docs/content/docs/developers/webhooks/verification.mdx index a6cac916d5..751d30d89a 100644 --- a/apps/docs/content/docs/developers/webhooks/verification.mdx +++ b/apps/docs/content/docs/developers/webhooks/verification.mdx @@ -255,6 +255,7 @@ const validEvents = [ 'DOCUMENT_REJECTED', 'DOCUMENT_CANCELLED', 'DOCUMENT_REMINDER_SENT', + 'RECIPIENT_EXPIRED', 'TEMPLATE_CREATED', 'TEMPLATE_UPDATED', 'TEMPLATE_DELETED', diff --git a/apps/docs/content/docs/self-hosting/deployment/docker-compose.mdx b/apps/docs/content/docs/self-hosting/deployment/docker-compose.mdx index 84e2281157..a5ac588070 100644 --- a/apps/docs/content/docs/self-hosting/deployment/docker-compose.mdx +++ b/apps/docs/content/docs/self-hosting/deployment/docker-compose.mdx @@ -81,7 +81,7 @@ services: - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?err} - POSTGRES_DB=${POSTGRES_DB:?err} healthcheck: - test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}'] + test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}'] interval: 10s timeout: 5s retries: 5 diff --git a/apps/docs/content/docs/self-hosting/deployment/docker.mdx b/apps/docs/content/docs/self-hosting/deployment/docker.mdx index 68508e7678..d8ba9c70ae 100644 --- a/apps/docs/content/docs/self-hosting/deployment/docker.mdx +++ b/apps/docs/content/docs/self-hosting/deployment/docker.mdx @@ -102,7 +102,7 @@ See [Email Configuration](/docs/self-hosting/configuration/email) for other tran | Variable | Description | Default | | ------------------------------------------- | -------------------------------------------------------------- | ------------------------- | | `PORT` | Port the application listens on | `3000` | -| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | Path to signing certificate inside container | `/opt/documenso/cert.p12` | +| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | Path to signing certificate inside container — set to the volume-mount path (e.g. `/opt/documenso/cert.p12`). Only Docker Compose defaults this; plain `docker run` must set it explicitly | - | | `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for the signing certificate | - | | `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | Base64-encoded `.p12` certificate (alternative to file path) | - | | `NEXT_PUBLIC_UPLOAD_TRANSPORT` | Document storage: `database` or `s3` | `database` | @@ -136,6 +136,7 @@ docker run -d \ -e NEXT_PUBLIC_WEBAPP_URL="https://sign.example.com" \ -e NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000" \ -e NEXT_PRIVATE_DATABASE_URL="postgresql://user:password@db-host:5432/documenso" \ + -e NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH="/opt/documenso/cert.p12" \ -e NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password" \ -e NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth" \ -e NEXT_PRIVATE_SMTP_HOST="smtp.example.com" \ @@ -154,6 +155,12 @@ A signing certificate is required for document signing. You have two options for - **Volume mount** — mount a `.p12` file from the host into the container at `/opt/documenso/cert.p12` (shown above). This is the simplest approach for small to moderate deployments. - **Base64-encoded contents** — set `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` with the base64-encoded certificate string. Use this when file mounting is not available (e.g., Railway, Vercel). + + Plain `docker run` deployments must set `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` explicitly. This + prevents production deployments from accidentally using the insecure example certificate. + Docker Compose sets the file path for you. + + For production deployments that require Adobe Approved Trust List recognition, consider using a [Google Cloud HSM](/docs/self-hosting/configuration/signing-certificate/google-cloud-hsm) or another external HSM. @@ -178,6 +185,7 @@ NEXT_PUBLIC_WEBAPP_URL=https://sign.example.com NEXT_PRIVATE_INTERNAL_WEBAPP_URL=http://localhost:3000 NEXT_PRIVATE_DATABASE_URL=postgresql://user:password@db-host:5432/documenso NEXT_PRIVATE_DIRECT_DATABASE_URL=postgresql://user:password@db-host:5432/documenso +NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=/opt/documenso/cert.p12 NEXT_PRIVATE_SIGNING_PASSPHRASE=your-certificate-password NEXT_PRIVATE_SMTP_TRANSPORT=smtp-auth NEXT_PRIVATE_SMTP_HOST=smtp.example.com @@ -203,6 +211,12 @@ docker run -d \ Documenso provides health check endpoints for monitoring: + + If a certificate is mounted but signing fails, ensure `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` + explicitly points to its path inside the container. Production does not use the development + example certificate as a fallback. + + | Endpoint | Purpose | | ------------------------- | -------------------------------------------------------------- | | `/api/health` | Checks database connectivity and certificate status | diff --git a/apps/docs/content/docs/self-hosting/deployment/manual.mdx b/apps/docs/content/docs/self-hosting/deployment/manual.mdx index d6dc4fda5b..70f7da640f 100644 --- a/apps/docs/content/docs/self-hosting/deployment/manual.mdx +++ b/apps/docs/content/docs/self-hosting/deployment/manual.mdx @@ -14,8 +14,8 @@ import { Step, Steps } from 'fumadocs-ui/components/steps'; ## Prerequisites -- Node.js 22 or later -- npm 11 or later +- Node.js 24 or later +- npm 11.17 or later - PostgreSQL 14 or later - A Linux server (for systemd service setup) diff --git a/apps/docs/content/docs/self-hosting/getting-started/requirements.mdx b/apps/docs/content/docs/self-hosting/getting-started/requirements.mdx index c64bd081e6..b750699061 100644 --- a/apps/docs/content/docs/self-hosting/getting-started/requirements.mdx +++ b/apps/docs/content/docs/self-hosting/getting-started/requirements.mdx @@ -141,8 +141,8 @@ If building from source (not using Docker images): | Requirement | Version | | ----------- | ------- | -| Node.js | 22+ | -| npm | 11+ | +| Node.js | 24+ | +| npm | 11.17+ | --- @@ -169,7 +169,7 @@ Documenso runs on: | MySQL/MariaDB | PostgreSQL-specific features required | | SQLite | Not suitable for production workloads | | MongoDB | Relational database required | -| Node.js < 22 | Modern JavaScript features required | +| Node.js < 24 | Modern JavaScript features required | --- diff --git a/apps/docs/content/docs/users/organisations/preferences/document.mdx b/apps/docs/content/docs/users/organisations/preferences/document.mdx index 1f4e82c08c..d812523256 100644 --- a/apps/docs/content/docs/users/organisations/preferences/document.mdx +++ b/apps/docs/content/docs/users/organisations/preferences/document.mdx @@ -34,7 +34,7 @@ To access the preferences, navigate to either the organisation or teams settings | **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. | +| **Delegate Document Ownership** | By default, documents created with a team API token are owned by the user who created the token. Enable this setting to let supported API requests assign 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. | Document visibility, language, and signature settings can be overridden per document. diff --git a/apps/docs/package.json b/apps/docs/package.json index da99666791..9539148d0c 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -3,20 +3,19 @@ "version": "0.0.0", "private": true, "scripts": { - "build": "NEXT_IGNORE_INCORRECT_LOCKFILE=true next build", + "build": "next build", "dev": "next dev", "start": "next start", "types:check": "fumadocs-mdx && next typegen && tsc --noEmit", "postinstall": "fumadocs-mdx" }, "dependencies": { - "@radix-ui/react-tabs": "^1.1.13", - "fumadocs-core": "16.5.0", - "fumadocs-mdx": "14.2.6", - "fumadocs-ui": "16.5.0", + "fumadocs-core": "16.14.3", + "fumadocs-mdx": "15.2.3", + "fumadocs-ui": "16.14.3", "lucide-react": "^0.563.0", "mermaid": "^11.12.2", - "next": "16.2.6", + "next": "16.3.0", "next-plausible": "^3.12.5", "next-themes": "^0.4.6", "react": "^19.2.4", diff --git a/apps/docs/src/components/mdx/envelope-warning.tsx b/apps/docs/src/components/mdx/envelope-warning.tsx new file mode 100644 index 0000000000..18676a78dd --- /dev/null +++ b/apps/docs/src/components/mdx/envelope-warning.tsx @@ -0,0 +1,19 @@ +import { Callout } from 'fumadocs-ui/components/callout'; + +const MIGRATION_GUIDE_HREF = '/docs/developers/api/migrate-to-envelopes'; + +/** + * Deprecation banner steering API consumers away from the legacy document and + * template create endpoints and towards the unified Envelope API. + * + * Registered globally in `mdx-components.tsx`, so it can be used in any MDX page + * as `` without an explicit import. + */ +export function EnvelopeWarning() { + return ( + + Documents and templates are being deprecated and replaced by envelopes.{' '} + Read the migration guide here. + + ); +} diff --git a/apps/docs/src/mdx-components.tsx b/apps/docs/src/mdx-components.tsx index 298b70960c..a0116880ae 100644 --- a/apps/docs/src/mdx-components.tsx +++ b/apps/docs/src/mdx-components.tsx @@ -1,6 +1,7 @@ import * as TabsComponents from 'fumadocs-ui/components/tabs'; import defaultMdxComponents from 'fumadocs-ui/mdx'; import type { MDXComponents } from 'mdx/types'; +import { EnvelopeWarning } from '@/components/mdx/envelope-warning'; import { Mermaid } from '@/components/mdx/mermaid'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -9,6 +10,7 @@ export function getMDXComponents(components?: MDXComponents): any { ...defaultMdxComponents, ...TabsComponents, Mermaid, + EnvelopeWarning, ...components, }; } diff --git a/apps/openpage-api/package.json b/apps/openpage-api/package.json index bcc93e039e..1b7c14350e 100644 --- a/apps/openpage-api/package.json +++ b/apps/openpage-api/package.json @@ -12,11 +12,11 @@ "dependencies": { "@documenso/prisma": "*", "luxon": "^3.7.2", - "next": "16.2.6" + "next": "16.3.0" }, "devDependencies": { "@types/node": "^20", - "@types/react": "18.3.27", + "@types/react": "^19.2.17", "typescript": "5.6.2" } } diff --git a/apps/remix/Dockerfile.bun b/apps/remix/Dockerfile.bun deleted file mode 100644 index 973038e8a3..0000000000 --- a/apps/remix/Dockerfile.bun +++ /dev/null @@ -1,25 +0,0 @@ -FROM oven/bun:1 AS dependencies-env -COPY . /app - -FROM dependencies-env AS development-dependencies-env -COPY ./package.json bun.lockb /app/ -WORKDIR /app -RUN bun i --frozen-lockfile - -FROM dependencies-env AS production-dependencies-env -COPY ./package.json bun.lockb /app/ -WORKDIR /app -RUN bun i --production - -FROM dependencies-env AS build-env -COPY ./package.json bun.lockb /app/ -COPY --from=development-dependencies-env /app/node_modules /app/node_modules -WORKDIR /app -RUN bun run build - -FROM dependencies-env -COPY ./package.json bun.lockb /app/ -COPY --from=production-dependencies-env /app/node_modules /app/node_modules -COPY --from=build-env /app/build /app/build -WORKDIR /app -CMD ["bun", "run", "start"] \ No newline at end of file diff --git a/apps/remix/Dockerfile.pnpm b/apps/remix/Dockerfile.pnpm deleted file mode 100644 index 57916afc2a..0000000000 --- a/apps/remix/Dockerfile.pnpm +++ /dev/null @@ -1,26 +0,0 @@ -FROM node:20-alpine AS dependencies-env -RUN npm i -g pnpm -COPY . /app - -FROM dependencies-env AS development-dependencies-env -COPY ./package.json pnpm-lock.yaml /app/ -WORKDIR /app -RUN pnpm i --frozen-lockfile - -FROM dependencies-env AS production-dependencies-env -COPY ./package.json pnpm-lock.yaml /app/ -WORKDIR /app -RUN pnpm i --prod --frozen-lockfile - -FROM dependencies-env AS build-env -COPY ./package.json pnpm-lock.yaml /app/ -COPY --from=development-dependencies-env /app/node_modules /app/node_modules -WORKDIR /app -RUN pnpm build - -FROM dependencies-env -COPY ./package.json pnpm-lock.yaml /app/ -COPY --from=production-dependencies-env /app/node_modules /app/node_modules -COPY --from=build-env /app/build /app/build -WORKDIR /app -CMD ["pnpm", "start"] \ No newline at end of file diff --git a/apps/remix/app/components/dialogs/document-preferences-reset-dialog.tsx b/apps/remix/app/components/dialogs/document-preferences-reset-dialog.tsx index 9adebb36e9..ed7184ef77 100644 --- a/apps/remix/app/components/dialogs/document-preferences-reset-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-preferences-reset-dialog.tsx @@ -18,7 +18,6 @@ export type DocumentPreferencesResetDialogProps = { onReset: () => Promise; showAiFeatures?: boolean; showDocumentVisibility?: boolean; - showIncludeSenderDetails?: boolean; }; export const DocumentPreferencesResetDialog = ({ @@ -26,7 +25,6 @@ export const DocumentPreferencesResetDialog = ({ onReset, showAiFeatures = false, showDocumentVisibility = false, - showIncludeSenderDetails = false, }: DocumentPreferencesResetDialogProps) => { const [open, setOpen] = useState(false); const [isResetting, setIsResetting] = useState(false); @@ -92,29 +90,12 @@ export const DocumentPreferencesResetDialog = ({
  • Default signature settings
  • - {showIncludeSenderDetails && ( -
  • - Send on behalf of team -
  • - )} -
  • - Include the signing certificate in the document -
  • -
  • - Include the audit logs in the document -
  • Default recipients
  • Delegate document ownership
  • -
  • - Default envelope expiration -
  • -
  • - Default signing reminders -
  • {showAiFeatures && (
  • AI features diff --git a/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx index dfacdec80b..4fc762f3c5 100644 --- a/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; @@ -71,6 +72,7 @@ export const EnvelopeDistributeDialog = ({ const { toast } = useToast(); const { t, i18n } = useLingui(); const navigate = useNavigate(); + const analytics = useAnalytics(); const [isOpen, setIsOpen] = useState(false); const [isSyncing, setIsSyncing] = useState(false); @@ -200,6 +202,12 @@ export const EnvelopeDistributeDialog = ({ } catch (err) { const error = AppError.parseError(err); + analytics.captureException(err, { + source: 'editor', + location: 'distribute_document', + envelopeId: envelope.id, + }); + const errorMessage = getDistributeErrorMessage(error.code); toast({ diff --git a/apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx b/apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx index 1a54c7893d..b2d5da5384 100644 --- a/apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; import { AppError } from '@documenso/lib/errors/app-error'; import type { TEnvelope } from '@documenso/lib/types/envelope'; @@ -51,6 +52,7 @@ export const EnvelopeRedistributeDialog = ({ envelope, envelopeType, trigger }: const { toast } = useToast(); const { t, i18n } = useLingui(); + const analytics = useAnalytics(); const [isOpen, setIsOpen] = useState(false); @@ -95,6 +97,13 @@ export const EnvelopeRedistributeDialog = ({ envelope, envelopeType, trigger }: setIsOpen(false); } catch (err) { const error = AppError.parseError(err); + + analytics.captureException(err, { + source: 'editor', + location: 'redistribute_document', + envelopeId: envelope.id, + }); + const errorMessage = getDistributeErrorMessage(error.code); toast({ diff --git a/apps/remix/app/components/dialogs/envelopes-bulk-download-dialog.tsx b/apps/remix/app/components/dialogs/envelopes-bulk-download-dialog.tsx new file mode 100644 index 0000000000..cc609fabb0 --- /dev/null +++ b/apps/remix/app/components/dialogs/envelopes-bulk-download-dialog.tsx @@ -0,0 +1,378 @@ +import { + createZipWriter, + sanitizeZipPathSegment, + type ZipFileEntry, +} from '@documenso/lib/client-only/create-zip-writer'; +import { downloadFile } from '@documenso/lib/client-only/download-file'; +import { fetchPDF } from '@documenso/lib/client-only/download-pdf'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; +import { RadioGroupSegmented, RadioGroupSegmentedItem } from '@documenso/ui/primitives/radio-group'; +import { useToast } from '@documenso/ui/primitives/use-toast'; +import { plural } from '@lingui/core/macro'; +import { Plural, Trans, useLingui } from '@lingui/react/macro'; +import { DocumentStatus } from '@prisma/client'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useEffect, useRef, useState } from 'react'; +import { match } from 'ts-pattern'; + +/** + * The maximum number of documents that can be downloaded in a single bulk + * download. Each document requires fetching its full PDFs into the browser, + * so this bounds both request volume and blob storage usage. Matches the + * spirit of the server-side 100 cap on bulk move/delete/cancel. + */ +export const MAX_BULK_DOWNLOAD_ENVELOPES = 50; + +type BulkDownloadVersion = 'signed' | 'original' | 'pending'; + +export type EnvelopeBulkDownloadItem = { + id: string; + title: string; + status: DocumentStatus; + + /** + * Whether the envelope is a legacy (v1) envelope. Legacy envelopes use a + * different field-rendering pipeline that the partial PDF helper does not + * implement, so the Partial option is hidden for them. + */ + isLegacy: boolean; +}; + +const getDefaultVersion = (envelope: EnvelopeBulkDownloadItem): BulkDownloadVersion => + envelope.status === DocumentStatus.COMPLETED ? 'signed' : 'original'; + +export type EnvelopesBulkDownloadDialogProps = { + envelopes: EnvelopeBulkDownloadItem[]; + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: (successfulEnvelopeIds: string[]) => void; +} & Omit; + +export const EnvelopesBulkDownloadDialog = ({ + envelopes, + open, + onOpenChange, + onSuccess, + ...props +}: EnvelopesBulkDownloadDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [versionMap, setVersionMap] = useState>({}); + const [progress, setProgress] = useState(0); + const [isDownloading, setIsDownloading] = useState(false); + + const abortRef = useRef(false); + + const trpcUtils = trpc.useUtils(); + + const isOverDownloadLimit = envelopes.length > MAX_BULK_DOWNLOAD_ENVELOPES; + + useEffect(() => { + if (!open) { + return; + } + + setVersionMap(Object.fromEntries(envelopes.map((envelope) => [envelope.id, getDefaultVersion(envelope)]))); + setProgress(0); + }, [open]); + + const getDownloadVersion = (envelope: EnvelopeBulkDownloadItem): BulkDownloadVersion => + versionMap[envelope.id] ?? getDefaultVersion(envelope); + + /** + * The version options selectable for an envelope, mirroring the gating used + * by the single envelope download dialog: + * - COMPLETED: signed or original. + * - PENDING (non-legacy): partial or original. Legacy envelopes use a + * field-rendering pipeline the partial PDF helper does not implement. + * - Anything else: original only, so no choice is shown. + */ + const getVersionOptions = ( + envelope: EnvelopeBulkDownloadItem, + ): { value: BulkDownloadVersion; label: string }[] | null => { + if (envelope.status === DocumentStatus.COMPLETED) { + return [ + { value: 'signed', label: t({ message: 'Signed', context: 'Signed document (adjective)' }) }, + { value: 'original', label: t({ message: 'Original', context: 'Original document (adjective)' }) }, + ]; + } + + if (envelope.status === DocumentStatus.PENDING && !envelope.isLegacy) { + return [ + { value: 'pending', label: t({ message: 'Partial', context: 'Partially signed document (adjective)' }) }, + { value: 'original', label: t({ message: 'Original', context: 'Original document (adjective)' }) }, + ]; + } + + return null; + }; + + const getStatusLabel = (status: DocumentStatus) => + match(status) + .with(DocumentStatus.COMPLETED, () => t`Completed`) + .with(DocumentStatus.PENDING, () => t`Pending`) + .with(DocumentStatus.DRAFT, () => t`Draft`) + .with(DocumentStatus.REJECTED, () => t`Rejected`) + .with(DocumentStatus.CANCELLED, () => t`Cancelled`) + .exhaustive(); + + const onDownload = async () => { + if (envelopes.length === 0 || isOverDownloadLimit || isDownloading) { + return; + } + + abortRef.current = false; + setIsDownloading(true); + setProgress(0); + + const zipWriter = createZipWriter(); + + const successfulEnvelopeIds: string[] = []; + let failedDownloads = 0; + + try { + for (const envelope of envelopes) { + if (abortRef.current) { + break; + } + + try { + const downloadVersion = getDownloadVersion(envelope); + + const { data: envelopeItems } = await trpcUtils.envelope.item.getManyByToken.fetch({ + envelopeId: envelope.id, + access: { + type: 'user', + }, + }); + + // Each envelope's items are grouped in their own folder. The id + // prefix guarantees uniqueness, the truncated title keeps it + // readable without risking overly long extraction paths. + const folderName = sanitizeZipPathSegment(`${envelope.id}_${envelope.title}`.slice(0, 96)); + + // Buffer this envelope's files before writing so a failed envelope + // is either fully in the zip or not at all. Files from previous + // envelopes have already been written to the zip stream and freed. + const envelopeFiles: ZipFileEntry[] = []; + + for (const envelopeItem of envelopeItems) { + const { filename, blob } = await fetchPDF({ + envelopeItem, + token: undefined, + fileName: envelopeItem.title, + version: downloadVersion, + }); + + envelopeFiles.push({ + filename: `${folderName}/${sanitizeZipPathSegment(filename)}`, + data: blob, + }); + } + + for (const file of envelopeFiles) { + await zipWriter.addFile(file); + } + + successfulEnvelopeIds.push(envelope.id); + } catch (error) { + console.error(error); + failedDownloads++; + } + + setProgress((p) => p + 1); + } + + // The user intentionally stopped the download, discard anything fetched + // so far without toasting an error. + if (abortRef.current) { + zipWriter.abort(); + return; + } + + if (successfulEnvelopeIds.length === 0) { + zipWriter.abort(); + + toast({ + title: t`Error`, + description: t`An error occurred while downloading the documents.`, + variant: 'destructive', + }); + return; + } + + try { + downloadFile({ + filename: `documenso-documents-${new Date().toISOString().slice(0, 10)}.zip`, + data: zipWriter.finalize(), + }); + } catch (error) { + console.error(error); + + zipWriter.abort(); + + toast({ + title: t`Error`, + description: t`An error occurred while downloading the documents.`, + variant: 'destructive', + }); + + return; + } + + if (failedDownloads > 0) { + toast({ + title: t`Documents partially downloaded`, + description: t`${plural(successfulEnvelopeIds.length, { + one: '# document downloaded.', + other: '# documents downloaded.', + })} ${plural(failedDownloads, { + one: '# document could not be downloaded.', + other: '# documents could not be downloaded.', + })}`, + variant: 'destructive', + }); + onSuccess?.(successfulEnvelopeIds); + return; + } + + toast({ + title: t`Documents downloaded`, + description: plural(successfulEnvelopeIds.length, { + one: '# document has been downloaded.', + other: '# documents have been downloaded.', + }), + }); + + onSuccess?.(successfulEnvelopeIds); + onOpenChange(false); + } finally { + setIsDownloading(false); + } + }; + + return ( + { + if (!isDownloading) { + onOpenChange(value); + } + }} + > + + + + Download Documents + + + + + + + + {isOverDownloadLimit && ( + + + + + + )} + +
    +
    +
    + {envelopes.map((envelope) => { + const versionOptions = getVersionOptions(envelope); + + return ( +
    +
    +

    + {envelope.title} +

    +

    {getStatusLabel(envelope.status)}

    +
    + + {versionOptions && ( + + setVersionMap((prev) => ({ + ...prev, + [envelope.id]: value as BulkDownloadVersion, + })) + } + aria-label={t`Download version for ${envelope.title}`} + > + {versionOptions.map((option) => ( + + {option.label} + + ))} + + )} +
    + ); + })} +
    +
    + + {isDownloading && ( +

    + + Downloading {progress} / {envelopes.length}... + +

    + )} + + + + + + +
    +
    +
    + ); +}; diff --git a/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx b/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx index 7c08cf7d3a..147cf3b40a 100644 --- a/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx @@ -17,28 +17,25 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { Prisma } from '@prisma/client'; +import type { Team, TeamEmail, TeamEmailVerification } from '@prisma/client'; import { useState } from 'react'; import { useRevalidator } from 'react-router'; export type TeamEmailDeleteDialogProps = { trigger?: React.ReactNode; teamName: string; - team: Prisma.TeamGetPayload<{ - include: { - teamEmail: true; - emailVerification: { - select: { - expiresAt: true; - name: true; - email: true; - }; - }; - }; - }>; + team: Pick; + teamEmail: Pick | null; + emailVerification: Pick | null; }; -export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDeleteDialogProps) => { +export const TeamEmailDeleteDialog = ({ + trigger, + teamName, + team, + teamEmail, + emailVerification, +}: TeamEmailDeleteDialogProps) => { const [open, setOpen] = useState(false); const { _ } = useLingui(); @@ -83,11 +80,11 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele }); const onRemove = async () => { - if (team.teamEmail) { + if (teamEmail) { await deleteTeamEmail({ teamId: team.id }); } - if (team.emailVerification) { + if (emailVerification) { await deleteTeamEmailVerification({ teamId: team.id }); } @@ -121,13 +118,13 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele - {team.teamEmail?.name || team.emailVerification?.name} + {teamEmail?.name || emailVerification?.name} } - secondaryText={{team.teamEmail?.email || team.emailVerification?.email}} + secondaryText={{teamEmail?.email || emailVerification?.email}} /> diff --git a/apps/remix/app/components/dialogs/team-email-update-dialog.tsx b/apps/remix/app/components/dialogs/team-email-update-dialog.tsx index 3fbddc3c2b..449d5ec363 100644 --- a/apps/remix/app/components/dialogs/team-email-update-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-email-update-dialog.tsx @@ -23,7 +23,8 @@ import { useRevalidator } from 'react-router'; import type { z } from 'zod'; export type TeamEmailUpdateDialogProps = { - teamEmail: TeamEmail; + teamId: number; + teamEmail: Pick; trigger?: React.ReactNode; } & Omit; @@ -33,7 +34,7 @@ const ZUpdateTeamEmailFormSchema = ZUpdateTeamEmailMutationSchema.pick({ type TUpdateTeamEmailFormSchema = z.infer; -export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmailUpdateDialogProps) => { +export const TeamEmailUpdateDialog = ({ teamId, teamEmail, trigger, ...props }: TeamEmailUpdateDialogProps) => { const [open, setOpen] = useState(false); const { t } = useLingui(); @@ -53,7 +54,7 @@ export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmai const onFormSubmit = async ({ name }: TUpdateTeamEmailFormSchema) => { try { await updateTeamEmail({ - teamId: teamEmail.teamId, + teamId, data: { name, }, diff --git a/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx b/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx index 7e381c82f7..af03b216d7 100644 --- a/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx @@ -1,4 +1,7 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { TBulkSendCsvError } from '@documenso/lib/server-only/template/validate-bulk-send-csv'; import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { Checkbox } from '@documenso/ui/primitives/checkbox'; import { @@ -15,9 +18,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; +import { Plural, Trans } from '@lingui/react/macro'; import { File as FileIcon, Upload, X } from 'lucide-react'; +import { useState } from 'react'; import { useForm } from 'react-hook-form'; +import { match } from 'ts-pattern'; import { z } from 'zod'; import { useCurrentTeam } from '~/providers/team'; @@ -29,6 +34,8 @@ const ZBulkSendFormSchema = z.object({ type TBulkSendFormSchema = z.infer; +type TBulkSendValidationError = TBulkSendCsvError | { type: 'UPLOAD_ERROR'; code: string }; + export type TemplateBulkSendDialogProps = { templateId: number; recipients: Array<{ email: string; name?: string | null }>; @@ -42,6 +49,9 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc const team = useCurrentTeam(); + const [open, setOpen] = useState(false); + const [validationError, setValidationError] = useState(null); + const form = useForm({ resolver: zodResolver(ZBulkSendFormSchema), defaultValues: { @@ -51,6 +61,20 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc const { mutateAsync: uploadBulkSend } = trpc.template.uploadBulkSend.useMutation(); + const onOpenChange = (value: boolean) => { + if (form.formState.isSubmitting) { + return; + } + + setOpen(value); + + if (!value) { + setValidationError(null); + + form.reset(); + } + }; + const onDownloadTemplate = () => { const headers = recipients.flatMap((_, index) => [`recipient_${index + 1}_email`, `recipient_${index + 1}_name`]); @@ -71,36 +95,44 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc }; const onSubmit = async (values: TBulkSendFormSchema) => { + setValidationError(null); + try { const csv = await values.file.text(); - await uploadBulkSend({ + const result = await uploadBulkSend({ templateId, teamId: team?.id, csv: csv, sendImmediately: values.sendImmediately, }); + if (!result.success) { + setValidationError(result.error); + + return; + } + toast({ title: _(msg`Success`), description: _(msg`Your bulk send has been initiated. You will receive an email notification upon completion.`), }); + setOpen(false); form.reset(); + onSuccess?.(); } catch (err) { console.error(err); - toast({ - title: _(msg`Error`), - description: _(msg`Failed to upload CSV. Please check the file format and try again.`), - variant: 'destructive', - }); + const error = AppError.parseError(err); + + setValidationError({ type: 'UPLOAD_ERROR', code: error.code }); } }; return ( - + {trigger ?? ( diff --git a/apps/remix/app/components/embed/authoring/configure-document-recipients.tsx b/apps/remix/app/components/embed/authoring/configure-document-recipients.tsx index 12180ab2aa..3818cfcf3d 100644 --- a/apps/remix/app/components/embed/authoring/configure-document-recipients.tsx +++ b/apps/remix/app/components/embed/authoring/configure-document-recipients.tsx @@ -18,6 +18,8 @@ import { useCallback, useRef } from 'react'; import type { Control } from 'react-hook-form'; import { useFieldArray, useFormContext, useFormState } from 'react-hook-form'; +import { useCspNonce } from '~/utils/nonce'; + import { useConfigureDocument } from './configure-document-context'; import type { TConfigureEmbedFormSchema } from './configure-document-view.types'; @@ -32,6 +34,7 @@ export interface ConfigureDocumentRecipientsProps { export const ConfigureDocumentRecipients = ({ control, isSubmitting }: ConfigureDocumentRecipientsProps) => { const { _ } = useLingui(); const { isTemplate } = useConfigureDocument(); + const cspNonce = useCspNonce(); const $sensorApi = useRef(null); @@ -212,6 +215,7 @@ export const ConfigureDocumentRecipients = ({ control, isSubmitting }: Configure /> { diff --git a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx index 20a0a23fa4..3e2ca1d38f 100644 --- a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx +++ b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'; import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n'; @@ -77,6 +78,7 @@ export const EmbedDirectTemplateClientPage = ({ }: EmbedDirectTemplateClientPageProps) => { const { _ } = useLingui(); const { toast } = useToast(); + const analytics = useAnalytics(); const [searchParams] = useSearchParams(); @@ -264,6 +266,13 @@ export const EmbedDirectTemplateClientPage = ({ const error = AppError.parseError(err); const errorMessage = getDirectTemplateErrorMessage(error.code); + analytics.captureException(err, { + source: 'embed', + location: 'direct_template', + recipientId: recipient.id, + envelopeId, + }); + toast({ title: _(errorMessage.title), description: _(errorMessage.description), @@ -308,6 +317,14 @@ export const EmbedDirectTemplateClientPage = ({ } } catch (err) { console.error(err); + + analytics.captureException(err, { + source: 'embed', + location: 'embed_init', + recipientId: recipient.id, + envelopeId, + }); + setHasFinishedInit(true); } diff --git a/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx b/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx index 669f5274cb..739a5c8a6a 100644 --- a/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx +++ b/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx @@ -1,6 +1,8 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'; import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { AppError } from '@documenso/lib/errors/app-error'; import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download'; @@ -32,6 +34,7 @@ import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react'; import { BrandingLogo } from '~/components/general/branding-logo'; import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy'; import { injectCss } from '~/utils/css-vars'; +import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages'; import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover'; import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider'; @@ -73,6 +76,7 @@ export const EmbedSignDocumentV1ClientPage = ({ }: EmbedSignDocumentV1ClientPageProps) => { const { _ } = useLingui(); const { toast } = useToast(); + const analytics = useAnalytics(); const { fullName, email, signature, setFullName, setEmail, setSignature } = useRequiredDocumentSigningContext(); @@ -152,6 +156,14 @@ export const EmbedSignDocumentV1ClientPage = ({ setHasCompletedDocument(true); } catch (err) { + analytics.captureException(err, { + source: 'embed', + location: 'complete_document', + recipientId: recipient.id, + documentId, + envelopeId, + }); + if (window.parent) { window.parent.postMessage( { @@ -162,9 +174,12 @@ export const EmbedSignDocumentV1ClientPage = ({ ); } + const error = AppError.parseError(err); + const toastMessage = getSigningCompletionErrorMessage(error.code); + toast({ - title: _(msg`Something went wrong`), - description: _(msg`We were unable to submit this document at this time. Please try again later.`), + title: _(toastMessage.title), + description: _(toastMessage.description), variant: 'destructive', }); } @@ -231,6 +246,15 @@ export const EmbedSignDocumentV1ClientPage = ({ } } catch (err) { console.error(err); + + analytics.captureException(err, { + source: 'embed', + location: 'embed_init', + recipientId: recipient.id, + documentId, + envelopeId, + }); + setHasFinishedInit(true); } diff --git a/apps/remix/app/components/embed/embed-document-signing-page-v2.tsx b/apps/remix/app/components/embed/embed-document-signing-page-v2.tsx index 9695117132..55e7cb6d1d 100644 --- a/apps/remix/app/components/embed/embed-document-signing-page-v2.tsx +++ b/apps/remix/app/components/embed/embed-document-signing-page-v2.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n'; import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema'; import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; @@ -25,6 +26,7 @@ export const EmbedSignDocumentV2ClientPage = ({ allowWhitelabelling = false, }: EmbedSignDocumentV2ClientPageProps) => { const { _ } = useLingui(); + const analytics = useAnalytics(); const { envelope, recipient, envelopeData, setFullName, setEmail, fullName, email } = useRequiredEnvelopeSigningContext(); @@ -170,6 +172,14 @@ export const EmbedSignDocumentV2ClientPage = ({ } } catch (err) { console.error(err); + + analytics.captureException(err, { + source: 'embed', + location: 'embed_init', + recipientId: recipient.id, + envelopeId: envelope.id, + }); + setHasFinishedInit(true); } diff --git a/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx b/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx index b144d1c465..8964033a64 100644 --- a/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx +++ b/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx @@ -1,3 +1,4 @@ +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download'; @@ -26,6 +27,7 @@ import { useState } from 'react'; import { match, P } from 'ts-pattern'; import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy'; +import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages'; import { useRequiredDocumentSigningContext } from '../../general/document-signing/document-signing-provider'; import { DocumentSigningRejectDialog } from '../../general/document-signing/document-signing-reject-dialog'; @@ -56,6 +58,7 @@ export const MultiSignDocumentSigningView = ({ }: MultiSignDocumentSigningViewProps) => { const { _ } = useLingui(); const { toast } = useToast(); + const analytics = useAnalytics(); const { fullName, email, signature, setFullName, setSignature } = useRequiredDocumentSigningContext(); @@ -100,6 +103,13 @@ export const MultiSignDocumentSigningView = ({ console.error(err); + analytics.captureException(err, { + source: 'embed', + location: 'sign_field', + recipientId, + documentId: document?.id, + }); + toast({ title: _(msg`Error`), description: _(msg`An error occurred while signing the document.`), @@ -119,6 +129,13 @@ export const MultiSignDocumentSigningView = ({ } console.error(err); + + analytics.captureException(err, { + source: 'embed', + location: 'remove_field', + recipientId, + documentId: document?.id, + }); } }; @@ -139,11 +156,21 @@ export const MultiSignDocumentSigningView = ({ recipientId, }); } catch (err) { + analytics.captureException(err, { + source: 'embed', + location: 'complete_document', + recipientId, + documentId: document?.id, + }); + onDocumentError?.(); + const error = AppError.parseError(err); + const toastMessage = getSigningCompletionErrorMessage(error.code); + toast({ - title: _(msg`Error`), - description: _(msg`Failed to complete the document. Please try again.`), + title: _(toastMessage.title), + description: _(toastMessage.description), variant: 'destructive', }); } finally { diff --git a/apps/remix/app/components/forms/2fa/two-factor-code-dialog.tsx b/apps/remix/app/components/forms/2fa/two-factor-code-dialog.tsx new file mode 100644 index 0000000000..79767f58f1 --- /dev/null +++ b/apps/remix/app/components/forms/2fa/two-factor-code-dialog.tsx @@ -0,0 +1,158 @@ +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; +import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input'; +import { Trans } from '@lingui/react/macro'; +import type React from 'react'; +import { useState } from 'react'; +import { type FieldValues, type Path, useFormContext } from 'react-hook-form'; +import { z } from 'zod'; + +/** + * Schema for forms that accept a two factor code. Compose with `.extend()` or `.merge()`. + */ +export const ZTwoFactorCodeFieldSchema = z.object({ + totpCode: z.string().trim().optional(), + backupCode: z.string().trim().optional(), +}); + +export type TTwoFactorCodeFieldSchema = z.infer; + +export const hasTwoFactorCode = (data: TTwoFactorCodeFieldSchema) => !!data.totpCode || !!data.backupCode; + +type TwoFactorMethod = 'totp' | 'backup'; + +export type TwoFactorCodeDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + isSubmitting?: boolean; + submitLabel: React.ReactNode; + + /** + * Called when the user submits the code. Typically the parent form's submit handler. + */ + onSubmit: () => void; +}; + +/** + * Collects a TOTP or backup code on top of an existing form, mirroring the + * sign in and disable 2FA dialogs. + * + * Must be rendered inside a `
    ` whose values include `totpCode` and `backupCode`. + */ +export const TwoFactorCodeDialog = ({ + open, + onOpenChange, + isSubmitting, + submitLabel, + onSubmit, +}: TwoFactorCodeDialogProps) => { + const form = useFormContext(); + + const [method, setMethod] = useState('totp'); + + const totpCodeName = 'totpCode' as Path; + const backupCodeName = 'backupCode' as Path; + + const onToggleMethod = () => { + form.resetField(totpCodeName); + form.resetField(backupCodeName); + + setMethod((current) => (current === 'totp' ? 'backup' : 'totp')); + }; + + const handleOpenChange = (value: boolean) => { + if (isSubmitting) { + return; + } + + if (!value) { + form.resetField(totpCodeName); + form.resetField(backupCodeName); + setMethod('totp'); + } + + onOpenChange(value); + }; + + return ( + + + + + Two-Factor Authentication + + + + {method === 'totp' ? ( + Enter the code from your authenticator app to continue. + ) : ( + Enter one of your backup codes to continue. + )} + + + +
    + {method === 'totp' && ( + ( + + + + {Array(6) + .fill(null) + .map((_, i) => ( + + + + ))} + + + + + )} + /> + )} + + {method === 'backup' && ( + ( + + + Backup Code + + + + + + + )} + /> + )} + + + + + + +
    +
    +
    + ); +}; diff --git a/apps/remix/app/components/forms/branding-preferences-form.tsx b/apps/remix/app/components/forms/branding-preferences-form.tsx index e556ff6cf4..1cb5be35d3 100644 --- a/apps/remix/app/components/forms/branding-preferences-form.tsx +++ b/apps/remix/app/components/forms/branding-preferences-form.tsx @@ -29,6 +29,7 @@ import { useOptionalCurrentTeam } from '~/providers/team'; import { useCspNonce } from '~/utils/nonce'; import { FormStickySaveBar } from './form-sticky-save-bar'; +import { InheritableField } from './inheritable-field'; const ZBrandingPreferencesFormSchema = z.object({ brandingEnabled: z.boolean().nullable(), @@ -210,11 +211,13 @@ export function BrandingPreferencesForm({ control={form.control} name="brandingEnabled" render={({ field }) => ( - - - Enable Custom Branding - - + Enable Custom Branding} + testId="branding-enabled" + > @@ -372,7 +379,7 @@ export function BrandingPreferencesForm({ )} - + )} /> @@ -380,11 +387,13 @@ export function BrandingPreferencesForm({ control={form.control} name="brandingCompanyDetails" render={({ field }) => ( - - - Brand Details - - + Brand Details} + testId="branding-company-details" + >