Compare commits

...
Author SHA1 Message Date
Catalin Pit b152d11461 Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-08-19 13:17:49 +03:00
Ephraim Duncan d42254ff52 docs(api): document cancel endpoint and fix get-many body shape (#3135)
## Description

Documents API page: adds the missing Cancel Document section and fixes a
fabricated request body on get-many that would fail schema validation
for anyone copying the docs.

## Changes Made

- Added a `## Cancel Document` section: `POST /envelope/cancel` with `{
envelopeId, reason? }`, PENDING-only (400 otherwise), not idempotent,
two-stage access (404 if not visible, 401 without owner/MANAGER+), fires
`DOCUMENT_CANCELLED` webhook, emails only SENT/OPENED non-CC
non-rejected recipients.
- Replaced the fabricated `envelopeIds: [...]` get-many body with the
real nested selector: `{ "ids": { "type": "envelopeId" | "documentId" |
"templateId", "ids": [...] } }` (string[] for envelopeId, number[]
otherwise, 1–20 IDs).
- Added the missing `### Response` for get-many (`{ "data": [...] }`)
and documented silent filtering of inaccessible IDs (no 404).
- Added `CANCELLED` to the status table, mermaid state diagram,
transitions prose, and filter values.
- Removed the nonexistent `source: "API"` value (real enum: `DOCUMENT |
TEMPLATE | TEMPLATE_DIRECT_LINK`).
- Fixed fabricated `pagination` wrappers to the real flat shape `{ data,
count, currentPage, perPage, totalPages }`; fixed Field `id` type and
mismatched code fences.
- Migration guide: warned that get-many's body shape changed from
`documentIds: number[]` — the breaking part of that migration.

## Testing Performed

Docs-only change (plus the migration guide). Verified against the
envelope-router types, `cancel-document.ts`, the cancel e2e spec, and
`schema.prisma`.
2026-08-19 09:28:55 +00:00
Ephraim Duncan 914e325486 docs(trpc): add openapi descriptions to envelope cancel, delete and update routes (#3134)
## Description

The envelope cancel, delete, and update routes rendered without
descriptions in the generated OpenAPI reference.

## Changes Made

- Added route-level OpenAPI `description` to `cancel-envelope.types.ts`,
`delete-envelope.types.ts`, and `update-envelope.types.ts`.
- Added field-level `.describe()` calls on request schemas, matching the
style of sibling envelope-router schemas (e.g.
`get-envelopes-by-ids.types.ts`, `distribute-envelope.types.ts`).

## Testing Performed

`npx tsc --noEmit -p packages/trpc` passes with no errors. Metadata-only
change — no runtime behavior affected.
2026-08-19 09:28:40 +00:00
Ephraim Duncan 05f646b326 docs(api): document rate limit headers and 429 variants (#3133)
## Description

The rate limits page claimed "No rate limit headers are currently
provided" and advised a fixed 60-second wait. The middleware has been
setting standard headers on every API response.

## Changes Made

- Documented `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and
`X-RateLimit-Reset` (Unix epoch seconds) on every `/api/v1`, `/api/v2`,
and `/api/v2-beta` response, and `Retry-After` (seconds, min 1) on 429s.
- Explained that windows are fixed epoch-aligned 1-minute buckets, so
the real wait is 1–60s — clients should honor `Retry-After` instead of
sleeping a fixed 60s.
- Showed both 429 body shapes: the global per-IP limiter's `{ "error":
... }` vs AppError-based `code`/`message`/`statusCode`.
- Covered the three distinct 429 sources: global per-IP limit,
organisation windowed limits, and monthly envelope quota (which sends no
rate-limit headers).
- Added `/api/v2-beta/*` to the documented scope; left the
verified-correct 1000/min figure and plan-limits table untouched.

## Testing Performed

Docs-only change. Verified against `rate-limit-middleware.ts`,
`rate-limit.ts`, `check-organisation-rate-limits.ts`,
`check-monthly-quota.ts`, and the remix server router.
2026-08-19 09:28:23 +00:00
Ephraim Duncan 0099dd672a feat(ui): redesign recipient field hover card (#3070)
Redesigns the popover shown when hovering a recipient field avatar in
the envelope view.

- Field-first hierarchy: header shows field-type icon + "{Type} field"
with inline status (Signed/Pending/Read Only) as a colored dot + label
- Recipient (name/email) moved to a recessed footer well as secondary
context
- Hide-field action moved from floating over the text to a ghost icon
button in the footer well
- Added a `FieldType` → icon map mirroring `field-selector.tsx`

## Screenshots

| Before | After |
| --- | --- |
| <img
src="https://raw.githubusercontent.com/ephraimduncan/documenso/assets-pr-3070-hover-card/.github/assets/hover-before.png"
width="320" alt="Previous hover tooltip: centered badge, title and
recipient text" /> | <img
src="https://raw.githubusercontent.com/ephraimduncan/documenso/assets-pr-3070-hover-card/.github/assets/hover-after.png"
width="320" alt="New hover card: field-first header with status,
recipient footer well" /> |
2026-08-19 09:23:48 +00:00
Lucas Smith 871c2a6f0e fix: surface actionable errors when completing documents (#3229) 2026-08-18 21:08:05 +10:00
David Nguyen 9bab1cddb3 chore: add github action timeouts (#3228) 2026-08-18 12:55:22 +10:00
Lucas Smith 3e0c1c444a chore: deps 2026-08-17 (#3225) 2026-08-18 12:54:54 +10:00
David Nguyen 6a8fe6f1ad chore: remove planning skills (#3177) 2026-08-18 12:23:28 +10:00
Catalin Pit 0557ce3dce Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-08-17 10:51:48 +03:00
Lucas Smith 779de01fe8 feat: migrate to react 19 (#3107) 2026-08-17 16:58:11 +10:00
Lucas Smith 283c6d274b fix: use documenso fork of skia-canvas for rendering (#3214)
Use our fork of `skia-canvas` for rendering which handles
encoding characters correctly with the caveat font and other
similar fonts that can group glyphs like ligatures.

This resolves issues with pdf text extraction where characters
were unable to be extracted due to lacking any data within the cmaps.
2026-08-17 15:23:19 +10:00
Catalin Pit 6b8eb9fc6a Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-07-16 08:43:44 +03:00
Catalin Pit 12d44e1c59 Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-07-08 10:34:25 +03:00
Catalin Pit 9dc66afc7b Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-07-06 09:44:38 +03:00
Catalin Pit 7a13be6bf2 Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-06-29 10:10:49 +03:00
Catalin Pit 1032028395 Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-06-25 09:02:29 +03:00
Catalin Pit 2396d0d5d0 refactor: improve logger for wrong pdf placeholders 2026-06-19 13:26:54 +03:00
Catalin Pit dac262edc9 chore: add comment 2026-06-19 10:36:48 +03:00
Catalin Pit 25eb4ffedf refactor: simplify placeholder key-value parsing logic in PDF helpers 2026-06-19 08:43:22 +03:00
Catalin Pit ee0ea82635 chore: add tests 2026-06-18 15:06:15 +03:00
Catalin Pit 5f7f6698fd refactor: improve error handling and validation for PDF field metadata parsing 2026-06-18 14:03:21 +03:00
Catalin Pit 402809c809 refactor: add detailed docs for placeholder string parsing functions 2026-06-18 11:52:27 +03:00
Catalin Pit 733e273c05 Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-06-18 09:32:18 +03:00
Catalin Pit 7c7933c2d4 Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-06-17 11:50:50 +03:00
Catalin Pit bb120d65dd Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-05-27 12:50:34 +03:00
Catalin Pit 9c14aa6297 Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-05-26 08:54:20 +03:00
Catalin Pit 6387e809a8 chore: merged main 2026-05-26 08:51:26 +03:00
Catalin Pit 5b2b1591a4 feat: enhance placeholder parsing and validation for selection fields in PDF helpers 2026-05-26 08:45:57 +03:00
Catalin Pit e5cb4c6bfd chore: refine dropdown placeholder support in PDF fields documentation and implementation 2026-05-22 11:27:18 +03:00
Catalin Pit 8185f916d3 chore: update plan and documentation 2026-05-22 09:00:46 +03:00
Catalin Pit c308dbf257 feat: support selection field options in PDF placeholders 2026-05-08 12:11:06 +03:00
76 changed files with 5457 additions and 6474 deletions
@@ -0,0 +1,222 @@
---
date: 2026-05-07
title: Pdf Placeholder Selection Fields
---
## Summary
Extend PDF placeholders so radio and dropdown fields can be configured from the existing Documenso placeholder syntax:
```text
{{FIELD_TYPE, RECIPIENT, key=value, key=value}}
```
Do not introduce a new delimiter style. Existing applications may already generate placeholders in this format, so the new selection-field behavior should fit into it.
## Goals
- Keep the current placeholder grammar unchanged.
- Support checkbox placeholders with option lists, checked values, validation, direction, required, read-only, and font size.
- Support radio placeholders with option lists, default/preselected values, direction, required, read-only, and font size.
- Support dropdown placeholders with option lists, default value, required, read-only, and font size.
- Use `options` as the only public list key in PDF placeholders.
- Convert `options` into internal `fieldMeta.values` during parsing.
- Make generated fields usable immediately in the editor, signing UI, preview renderer, and final PDF export.
## Non-Goals
- No semicolon placeholder syntax.
- No `values` alias in PDF placeholder syntax.
- No database migration.
- No behavior change for existing placeholders such as `{{text, r1, required=true}}`.
## Placeholder Syntax
Use the existing comma-separated placeholder format:
```text
{{checkbox, r1, options=Email|SMS|Phone, checked=Email|Phone, validationRule=atLeast, validationLength=1}}
{{radio, r1, options=Card|Bank transfer|Check, defaultValue=Check}}
{{radio, r1, options=Basic|Pro|Enterprise, selected=Pro, direction=horizontal}}
{{dropdown, r1, options=United States|Canada|United Kingdom}}
{{dropdown, r2, options=Sales|Legal|Finance, defaultValue=Legal}}
```
Use `|` inside `options` because `,` is already the top-level placeholder delimiter.
Parsing rules:
- Split top-level placeholder tokens on unescaped commas.
- Split metadata tokens on the first unescaped equals sign.
- Split `options` on unescaped pipes.
- Trim option values and drop empty values.
- Preserve option order.
- Support escaped delimiters: `\,`, `\=`, and `\|`.
- Treat field type values case-insensitively.
## Field Type Mapping
- `checkbox` maps to `FieldType.CHECKBOX`.
- `radio` maps to `FieldType.RADIO`.
- `dropdown` maps to `FieldType.DROPDOWN`.
## Metadata Mapping
### Checkbox
Example:
```text
{{checkbox, r1, options=Email|SMS|Phone, checked=Email|Phone, validationRule=atLeast, validationLength=1}}
```
Normalize to:
```ts
{
type: FieldType.CHECKBOX,
fieldMeta: {
type: 'checkbox',
validationRule: 'Select at least',
validationLength: 1,
values: [
{ id: 1, value: 'Email', checked: true },
{ id: 2, value: 'SMS', checked: false },
{ id: 3, value: 'Phone', checked: true },
],
},
}
```
Accepted keys:
- `options`
- `checked`
- `direction=vertical|horizontal`
- `validationRule=atLeast|exactly|atMost`
- `validationLength=1`
- `required=true|false`
- `readOnly=true|false`
- `fontSize=12`
Map checkbox validation aliases internally: `atLeast` -> `Select at least`, `exactly` -> `Select exactly`, `atMost` -> `Select at most`.
Checkbox placeholders do not support `label` or `placeholder` metadata.
### Radio
Example:
```text
{{radio, r1, options=Card|Bank transfer|Check, selected=Bank transfer}}
```
Normalize to:
```ts
{
type: FieldType.RADIO,
fieldMeta: {
type: 'radio',
values: [
{ id: 1, value: 'Card', checked: false },
{ id: 2, value: 'Bank transfer', checked: true },
{ id: 3, value: 'Check', checked: false },
],
},
}
```
Accepted keys:
- `options`
- `selected`, `default`, or `defaultValue`
- `direction=vertical|horizontal`
- `required=true|false`
- `readOnly=true|false`
- `fontSize=12`
Radio placeholders do not support `label` or `placeholder` metadata.
### Dropdown
Example:
```text
{{dropdown, r1, options=Sales|Legal|Finance, defaultValue=Legal}}
```
Normalize to:
```ts
{
type: FieldType.DROPDOWN,
fieldMeta: {
type: 'dropdown',
values: [{ value: 'Sales' }, { value: 'Legal' }, { value: 'Finance' }],
defaultValue: 'Legal',
},
}
```
Accepted keys:
- `options`
- `selected`, `default`, or `defaultValue`
- `required=true|false`
- `readOnly=true|false`
- `fontSize=12`
`defaultValue` should only be set if it matches one parsed option.
Dropdown placeholders do not support `label` or `placeholder` metadata.
## Code Touchpoints
- `packages/lib/server-only/pdf/helpers.ts`
- Extend `parseFieldMetaFromPlaceholder` so `options` normalizes into checkbox/radio/dropdown `fieldMeta.values`.
- Add delimiter-aware helpers for commas, equals signs, and pipes.
- `packages/lib/server-only/pdf/auto-place-fields.ts`
- Replace plain comma splitting with delimiter-aware splitting.
- Preserve the existing positional structure: field type, recipient, metadata.
- `packages/lib/types/field-meta.ts`
- Keep current internal schemas: checkbox/radio/dropdown still store options as `fieldMeta.values`.
- `packages/ui/primitives/document-flow/field-content.tsx`
- Display a radio fallback when a placeholder-created radio has no options.
- Docs:
- `apps/docs/content/docs/users/documents/advanced/pdf-placeholders.mdx`
- `apps/docs/content/docs/developers/api/fields.mdx`
## Test Plan
Unit tests:
- `options=Yes|No|Maybe` becomes stable radio values.
- `selected=No` marks only the matching radio option checked.
- Checkbox `options`, `checked`, `validationRule`, and `validationLength` normalize correctly.
- Dropdown `options` and `defaultValue` normalize correctly.
- Escaped delimiters parse correctly, for example `options=Sales\|Ops|Legal\, Compliance|A\=B`.
E2E/API tests:
- Add a PDF fixture with checkbox, radio, and dropdown placeholders using the current syntax.
- Verify created fields have schema-compatible metadata and expected options/defaults.
Suggested verification:
```bash
npm run test -w @documenso/lib -- server-only/pdf/helpers.test.ts
npm run test:dev -w @documenso/app-tests -- e2e/auto-placing-fields/auto-place-fields-document.spec.ts
npm run test:dev -w @documenso/app-tests -- e2e/envelope-editor-v2/envelope-fields.spec.ts
npx tsc --noEmit -p packages/lib/tsconfig.json
npx tsc --noEmit -p apps/remix/tsconfig.json
```
Do not use `npm run build` for routine verification unless explicitly requested.
## Decisions
- Keep the existing placeholder format.
- Use only `options` publicly.
- Keep `values` as an internal metadata field only.
- Use `|` as the option delimiter inside `options`.
@@ -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.
-56
View File
@@ -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.
-56
View File
@@ -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.
+2
View File
@@ -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
+1
View File
@@ -11,6 +11,7 @@ jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
actions: read
contents: read
+1
View File
@@ -8,6 +8,7 @@ on:
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout code
+1
View File
@@ -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
+1
View File
@@ -7,6 +7,7 @@ on:
jobs:
label_issues:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
issues: write
steps:
+1
View File
@@ -13,6 +13,7 @@ jobs:
contents: read
pull-requests: write
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/labeler@v4
with:
+2
View File
@@ -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
@@ -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
+1
View File
@@ -7,6 +7,7 @@ on:
jobs:
stale:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
issues: write
pull-requests: write
@@ -18,6 +18,7 @@ jobs:
pull_translations:
name: Force pull translations
runs-on: ubuntu-latest
timeout-minutes: 10
environment: Translations
permissions:
contents: write
+1
View File
@@ -16,6 +16,7 @@ jobs:
pull_translations:
name: Pull translations
runs-on: ubuntu-latest
timeout-minutes: 10
environment: Translations
permissions:
contents: write
@@ -14,6 +14,7 @@ jobs:
extract_translations:
name: Extract and upload translations
runs-on: ubuntu-latest
timeout-minutes: 30
environment: Translations
permissions:
contents: write
+1 -1
View File
@@ -1,3 +1,3 @@
legacy-peer-deps = true
prefer-dedupe = true
# min-release-age = 7
min-release-age = 7
@@ -29,14 +29,25 @@ 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`) |
| `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`, or `REJECTED` |
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, or `CANCELLED` |
| `title` | string | Document title |
| `source` | string | How the document was created: `DOCUMENT`, `TEMPLATE`, `API` |
| `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 |
@@ -44,19 +55,35 @@ A document object contains the following properties:
| `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,
@@ -73,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.",
@@ -99,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.
@@ -114,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) |
@@ -154,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(
@@ -197,12 +244,10 @@ const pendingDocs = await pendingResponse.json();
]
}
],
"pagination": {
"page": 1,
"count": 42,
"currentPage": 1,
"perPage": 10,
"totalPages": 5,
"totalItems": 42
}
"totalPages": 5
}
```
@@ -628,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
<Tabs items={['curl', 'TypeScript']}>
<Tab value="curl">
```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."
}'
```
</Tab>
<Tab value="TypeScript">
```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();
```
</Tab>
</Tabs>
### 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.
@@ -670,7 +781,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/delete',
const { success } = await response.json();
````
```
</Tab>
</Tabs>
@@ -680,7 +791,7 @@ const { success } = await response.json();
{
"success": true
}
````
```
---
@@ -695,8 +806,10 @@ POST /envelope/get-many
### Request Body
| Field | Type | Required | Description |
| ------------- | ----- | -------- | --------------------- |
| `envelopeIds` | array | Yes | Array of document IDs |
| ---------- | ------ | -------- | ---------------------------------------------------------------------------- |
| `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
@@ -707,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"]
}
}'
```
</Tab>
<Tab value="TypeScript">
```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: {
@@ -720,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();
````
```
</Tab>
</Tabs>
### 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
@@ -740,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
@@ -747,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
<Callout type="warn">
You cannot modify recipients or fields after a document moves to `PENDING` status.
@@ -773,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
@@ -800,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++;
}
@@ -119,7 +119,7 @@ Full reference in the [V2 OpenAPI reference](https://openapi.documenso.com).
| ------------------------------------------------- | ----------------------------------------------------- |
| `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` |
| `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` |
@@ -140,7 +140,7 @@ Full reference in the [V2 OpenAPI reference](https://openapi.documenso.com).
| ------------------------------------- | ------------------------------------------------ |
| `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` |
| `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` |
@@ -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.
</Callout>
### 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.
}
```
<Callout type="warn">
No rate limit headers are currently provided. When you receive a 429 response, wait at least 60
seconds before retrying.
</Callout>
### 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,25 +62,56 @@ 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
When you exceed a resource limit:
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:
```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."
}
```
On `/api/v2/*` and `/api/v2-beta/*`, the body is a structured error object:
```json
{
"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 |
| ------------------- | ------ | ------------------------------------------------------------------ |
| `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.
---
## See Also
@@ -1000,9 +1000,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;
}
@@ -109,6 +109,37 @@ You can customize fields by adding options after the recipient identifier:
| `maxValue` | Number | Maximum allowed value |
| `numberFormat` | Format string | Number display format |
### Selection Field Options
Checkbox, radio, and dropdown placeholders can define their selectable choices via the `options` property.
Separate choices with pipe (`|`) characters.
Checkbox, radio, and dropdown placeholders do not support `label` or `placeholder` metadata.
| Option | Applies To | Values | Description |
| ------------------ | ------------------------- | ------------------------ | ---------------------------------------- |
| `options` | Checkbox, Radio, Dropdown | `Option 1|Option 2` | Selectable choices |
| `checked` | Checkbox | `Option 1|Option 2` | Pre-checked choices |
| `selected` | Radio, Dropdown | One option value | Pre-selected/default choice |
| `default` | Radio, Dropdown | One option value | Alias for `selected` |
| `defaultValue` | Radio, Dropdown | One option value | Alias for `selected` |
| `direction` | Checkbox, Radio | `vertical`, `horizontal` | Option layout |
| `validationRule` | Checkbox | `atLeast`, `exactly`, `atMost` | Checkbox selection validation rule |
| `validationLength` | Checkbox | Number (e.g., `1`) | Checkbox validation option count |
| `required` | Checkbox, Radio, Dropdown | `true`, `false` | Whether the field must be completed |
| `readOnly` | Checkbox, Radio, Dropdown | `true`, `false` | Whether the pre-selected value is locked |
| `fontSize` | Checkbox, Radio, Dropdown | Number (e.g., `12`) | Field text size |
For checkbox validation, `validationLength` defines the option count:
- `atLeast` means at least that many options must be selected
- `exactly` means exactly that many options must be selected
- `atMost` means at most that many options must be selected
If an option needs a literal delimiter, escape it with a backslash:
```
{{dropdown, r1, options=Sales\|Ops|Legal\, Compliance|A\=B}}
```
### Examples with Options
```
@@ -116,6 +147,10 @@ You can customize fields by adding options after the recipient identifier:
{{number, r1, minValue=0, maxValue=100, value=50}}
{{name, r1, fontSize=14}}
{{text, r2, readOnly=true, text=Contract #12345}}
{{checkbox, r1, options=Email|SMS|Phone, checked=Email|Phone, validationRule=atLeast, validationLength=1}}
{{radio, r1, options=Card|Bank transfer|Check, selected=Check}}
{{dropdown, r1, options=United States|Canada|United Kingdom}}
{{dropdown, r2, options=Sales|Legal|Finance, defaultValue=Legal}}
```
<Callout type="info">
+4 -5
View File
@@ -10,13 +10,12 @@
"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",
+2 -2
View File
@@ -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"
}
}
@@ -1,6 +1,7 @@
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 +33,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';
@@ -162,9 +164,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',
});
}
@@ -26,6 +26,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';
@@ -141,9 +142,12 @@ export const MultiSignDocumentSigningView = ({
} catch (err) {
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 {
@@ -19,7 +19,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { keepPreviousData } from '@tanstack/react-query';
import { commandScore } from 'cmdk/dist/command-score';
import { defaultFilter as commandScore } from 'cmdk';
import {
ArrowLeftIcon,
CheckIcon,
@@ -14,6 +14,7 @@ import {
} from '@documenso/ui/primitives/dialog';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client';
@@ -27,6 +28,8 @@ import { useEmbedSigningContext } from '~/components/embed/embed-signing-context
import { AccessAuth2FAForm } from '~/components/general/document-signing/access-auth-2fa-form';
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
export type DocumentSigningCompleteDialogProps = {
@@ -85,7 +88,8 @@ export const DocumentSigningCompleteDialog = ({
position,
disableNameInput = false,
}: DocumentSigningCompleteDialogProps) => {
const { t } = useLingui();
const { t, i18n } = useLingui();
const { toast } = useToast();
const [showDialog, setShowDialog] = useState(false);
@@ -174,6 +178,18 @@ export const DocumentSigningCompleteDialog = ({
return;
}
// This dialog owns the completion error toast for every signing surface
// so the user gets a specific, actionable message. Callers should run
// their own side effects (e.g. embeds posting document-error) and
// rethrow rather than toasting themselves.
const toastMessage = getSigningCompletionErrorMessage(err.code);
toast({
title: i18n._(toastMessage.title),
description: i18n._(toastMessage.description),
variant: 'destructive',
});
}
};
@@ -1,3 +1,4 @@
import { AppError } from '@documenso/lib/errors/app-error';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
@@ -19,6 +20,8 @@ import { useId, useMemo, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages';
import { AssistantConfirmationDialog, type NextSigner } from '../../dialogs/assistant-confirmation-dialog';
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
import { useRequiredDocumentSigningContext } from './document-signing-provider';
@@ -100,9 +103,12 @@ export const DocumentSigningForm = ({
try {
await completeDocument({ nextSigner });
} catch (err) {
const error = AppError.parseError(err);
const toastMessage = getSigningCompletionErrorMessage(error.code);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while completing the document. Please try again.`),
title: _(toastMessage.title),
description: _(toastMessage.description),
variant: 'destructive',
});
@@ -146,14 +146,17 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
{isLoading && <DocumentSigningFieldsLoader />}
{!field.inserted && (
<RadioGroup onValueChange={(value) => handleSelectItem(value)} className="z-10 my-0.5 gap-y-1">
<RadioGroup
value={selectedOption}
onValueChange={(value) => handleSelectItem(value)}
className="z-10 my-0.5 gap-y-1"
>
{values?.map((item, index) => (
<div key={index} className="flex items-center">
<RadioGroupItem
className="h-3 w-3 shrink-0"
value={item.value}
id={`option-${field.id}-${item.id}`}
checked={item.checked}
disabled={isReadOnly}
/>
{!item.value.includes('empty-value-') && item.value && (
@@ -167,14 +170,13 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
)}
{field.inserted && (
<RadioGroup className="my-0.5 gap-y-1">
<RadioGroup value={field.customText ?? ''} className="my-0.5 gap-y-1">
{values?.map((item, index) => (
<div key={index} className="flex items-center">
<RadioGroupItem
className="h-3 w-3"
value={item.value}
id={`option-${field.id}-${item.id}`}
checked={item.value === field.customText}
disabled={isReadOnly}
/>
{!item.value.includes('empty-value-') && item.value && (
@@ -148,15 +148,11 @@ export const EnvelopeSignerCompleteDialog = () => {
const error = AppError.parseError(err);
if (error.code !== AppErrorCode.TWO_FACTOR_AUTH_FAILED) {
toast({
title: t`Something went wrong`,
description: t`We were unable to submit this document at this time. Please try again later.`,
variant: 'destructive',
});
onDocumentError?.();
}
// Rethrow so DocumentSigningCompleteDialog can handle 2FA retries and
// toast a specific completion error message.
throw err;
}
};
@@ -224,14 +220,11 @@ export const EnvelopeSignerCompleteDialog = () => {
}
} catch (err) {
console.log('err', err);
toast({
title: t`Something went wrong`,
description: t`We were unable to submit this document at this time. Please try again later.`,
variant: 'destructive',
});
onDocumentError?.();
// Rethrow so DocumentSigningCompleteDialog can toast a specific
// completion error message.
throw err;
}
};
@@ -215,7 +215,7 @@ export default function PDFViewer({
type VirtualizedPageListProps = {
scrollParentRef: ScrollTarget;
constraintRef: React.RefObject<HTMLDivElement>;
constraintRef: React.RefObject<HTMLDivElement | null>;
pages: PageMeta[];
numPages: number;
pdf: pdfjsLib.PDFDocumentProxy;
+21
View File
@@ -31,6 +31,26 @@ function initPosthog() {
}
}
/**
* Surfaces hydration recoveries (React 19 discards the server HTML and
* re-renders on the client instead of dying) so we can track how often
* extensions/early clicks interfere with hydration in the wild.
*/
function onRecoverableError(error: unknown, errorInfo: { componentStack?: string }) {
console.error('[hydration] recovered from error', error, errorInfo.componentStack);
if (extractPostHogConfig()) {
void import('posthog-js').then(({ default: posthog }) => {
if (posthog.__loaded) {
posthog.capture('$hydration_recoverable_error', {
message: error instanceof Error ? error.message : String(error),
componentStack: errorInfo.componentStack,
});
}
});
}
}
async function main() {
const locale = detect(fromHtmlTag('lang')) || 'en';
@@ -44,6 +64,7 @@ async function main() {
<HydratedRouter />
</I18nProvider>
</StrictMode>,
{ onRecoverableError },
);
});
@@ -42,6 +42,51 @@ export const getDirectTemplateErrorMessage = (code: string): ToastMessageDescrip
}));
};
/**
* Toast messages for errors thrown while a recipient attempts to complete
* (sign) a document, so the user knows whether retrying can help and what to
* do next.
*/
export const getSigningCompletionErrorMessage = (code: string): ToastMessageDescriptor => {
return match(code)
.with(AppErrorCode.NOT_FOUND, () => ({
title: msg`Document no longer available`,
description: msg`This document can no longer be signed. It may have been removed by the sender, or your signing access may have been revoked. Please contact the sender for a new signing link.`,
}))
.with(AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS, () => ({
title: msg`Some fields were not saved`,
description: msg`One or more of your required fields have not been saved. Please refresh the page, complete any empty required fields, and try again.`,
}))
.with(AppErrorCode.RECIPIENT_OUT_OF_TURN, () => ({
title: msg`It's not your turn to sign yet`,
description: msg`This document is signed in a set order and other recipients must sign before you. You will receive an email when it is your turn.`,
}))
.with(AppErrorCode.RECIPIENT_EXPIRED, () => ({
title: msg`Signing link expired`,
description: msg`Your signing link has expired. Please contact the sender to request a new one.`,
}))
.with(AppErrorCode.ENVELOPE_COMPLETED, () => ({
title: msg`Document already completed`,
description: msg`This document has already been completed and no further signatures can be added.`,
}))
.with(AppErrorCode.ENVELOPE_REJECTED, () => ({
title: msg`Document rejected`,
description: msg`This document has been rejected by a recipient and can no longer be signed.`,
}))
.with(AppErrorCode.ENVELOPE_CANCELLED, () => ({
title: msg`Document cancelled`,
description: msg`This document has been cancelled by the sender and can no longer be signed. Please contact the sender if you believe this is a mistake.`,
}))
.with(AppErrorCode.ENVELOPE_DRAFT, () => ({
title: msg`Document not ready`,
description: msg`This document has not been sent for signing yet. Please wait for the sender to send it before signing.`,
}))
.otherwise(() => ({
title: msg`Something went wrong`,
description: msg`We were unable to submit this document at this time. Please try again later.`,
}));
};
export const getUploadErrorMessage = (code: string): ToastMessageDescriptor => {
return match(code)
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
+5 -5
View File
@@ -57,9 +57,9 @@
"papaparse": "^5.5.3",
"posthog-js": "^1.297.2",
"posthog-node": "4.18.0",
"react": "^18",
"react": "^19.2.7",
"react-call": "^1.8.1",
"react-dom": "^18",
"react-dom": "^19.2.7",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.66.1",
"react-hotkeys-hook": "^4.6.2",
@@ -93,11 +93,11 @@
"@types/luxon": "^3.7.1",
"@types/node": "^20",
"@types/papaparse": "^5.5.0",
"@types/react": "18.3.27",
"@types/react-dom": "^18",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/ua-parser-js": "^0.7.39",
"cross-env": "^10.1.0",
"esbuild": "^0.27.0",
"esbuild": "^0.28.1",
"remix-flat-routes": "^0.8.5",
"rollup": "^4.53.3",
"tsx": "^4.23.1",
+1 -1
View File
@@ -121,7 +121,7 @@ export default defineConfig({
'nodemailer',
/playwright/,
'@playwright/browser-chromium',
'skia-canvas',
'@documenso/skia-canvas',
],
},
},
-3
View File
@@ -76,7 +76,6 @@ COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/package-lock.json ./package-lock.json
COPY --from=builder /app/lingui.config.ts ./lingui.config.ts
COPY --from=builder /app/patches ./patches
RUN npm ci
@@ -115,8 +114,6 @@ WORKDIR /app
COPY --from=builder --chown=nodejs:nodejs /app/out/json/ .
# Copy the tailwind config files across
COPY --from=builder --chown=nodejs:nodejs /app/out/full/packages/tailwind-config ./packages/tailwind-config
# Copy the patches across
COPY --from=builder --chown=nodejs:nodejs /app/patches ./patches
RUN npm ci --only=production
+3849 -4782
View File
File diff suppressed because it is too large Load Diff
+44 -7
View File
@@ -48,20 +48,23 @@
},
"devDependencies": {
"@biomejs/biome": "2.4.8",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@commitlint/cli": "^20.1.0",
"@commitlint/config-conventional": "^20.0.0",
"@datadog/pprof": "^5.13.5",
"@documenso/skia-canvas": "^3.0.8-documenso.3",
"@lingui/cli": "^5.6.0",
"@prisma/client": "^6.19.0",
"@trpc/client": "11.8.1",
"@trpc/react-query": "11.8.1",
"@trpc/server": "11.8.1",
"@trpc/client": "11.17.0",
"@trpc/react-query": "11.17.0",
"@trpc/server": "11.17.0",
"@ts-rest/core": "^3.52.1",
"@ts-rest/open-api": "^3.52.1",
"@ts-rest/serverless": "^3.52.1",
"dotenv": "^17.2.3",
"dotenv-cli": "^11.0.0",
"esbuild": "^0.27.0",
"esbuild": "^0.28.1",
"husky": "^9.1.7",
"inngest": "^3.54.0",
"inngest-cli": "^1.17.9",
@@ -86,27 +89,61 @@
"zod-prisma-types": "3.3.5"
},
"dependencies": {
"@ai-sdk/google-vertex": "3.0.81",
"@ai-sdk/google-vertex": "5.0.48",
"@documenso/prisma": "*",
"@libpdf/core": "^0.4.1",
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"@prisma/extension-read-replicas": "^0.4.1",
"ai": "^5.0.104",
"@radix-ui/react-accordion": "^1.2.16",
"@radix-ui/react-alert-dialog": "^1.1.19",
"@radix-ui/react-aspect-ratio": "^1.1.11",
"@radix-ui/react-avatar": "^1.2.2",
"@radix-ui/react-checkbox": "^1.3.7",
"@radix-ui/react-collapsible": "^1.1.16",
"@radix-ui/react-context-menu": "^2.3.3",
"@radix-ui/react-dialog": "^1.1.19",
"@radix-ui/react-dropdown-menu": "^2.1.20",
"@radix-ui/react-hover-card": "^1.1.19",
"@radix-ui/react-label": "^2.1.11",
"@radix-ui/react-menubar": "^1.1.20",
"@radix-ui/react-navigation-menu": "^1.2.18",
"@radix-ui/react-popover": "^1.1.19",
"@radix-ui/react-progress": "^1.1.12",
"@radix-ui/react-radio-group": "^1.4.3",
"@radix-ui/react-scroll-area": "^1.2.14",
"@radix-ui/react-select": "^2.3.3",
"@radix-ui/react-separator": "^1.1.11",
"@radix-ui/react-slider": "^1.4.3",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-switch": "^1.3.3",
"@radix-ui/react-tabs": "^1.1.17",
"@radix-ui/react-toast": "^1.2.19",
"@radix-ui/react-toggle": "^1.1.14",
"@radix-ui/react-toggle-group": "^1.1.15",
"@radix-ui/react-tooltip": "^1.2.12",
"ai": "^7.0.58",
"cron-parser": "^5.5.0",
"fflate": "^0.8.3",
"luxon": "^3.7.2",
"patch-package": "^8.0.1",
"posthog-node": "4.18.0",
"react": "^18",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"sharp": "0.35.3",
"typescript": "5.6.2",
"@marsidev/react-turnstile": "^1.5.0",
"zod": "^3.25.76"
},
"overrides": {
"lodash": "4.18.1",
"brace-expansion@1": "^1.1.18",
"pdfjs-dist": "5.4.296",
"postcss": "^8.5.19",
"react": "$react",
"react-dom": "$react-dom",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"typescript": "5.6.2",
"zod": "$zod",
"fumadocs-mdx": {
+1 -1
View File
@@ -19,7 +19,7 @@
"arctic": "^3.7.0",
"hono": "^4.12.14",
"luxon": "^3.7.2",
"react": "^18",
"react": "^19.2.7",
"ts-pattern": "^5.9.0",
"zod": "^3.25.76"
}
+19 -17
View File
@@ -1,17 +1,19 @@
export { Body } from '@react-email/body';
export { Button } from '@react-email/button';
export { Column } from '@react-email/column';
export { Container } from '@react-email/container';
export { Font } from '@react-email/font';
export { Head } from '@react-email/head';
export { Heading } from '@react-email/heading';
export { Hr } from '@react-email/hr';
export { Html } from '@react-email/html';
export { Img } from '@react-email/img';
export { Link } from '@react-email/link';
export { Preview } from '@react-email/preview';
export { render } from '@react-email/render';
export { Row } from '@react-email/row';
export { Section } from '@react-email/section';
export { Tailwind } from '@react-email/tailwind';
export { Text } from '@react-email/text';
export {
Body,
Button,
Column,
Container,
Font,
Head,
Heading,
Hr,
Html,
Img,
Link,
Preview,
Row,
render,
Section,
Tailwind,
Text,
} from 'react-email';
+2 -20
View File
@@ -19,27 +19,9 @@
"dependencies": {
"@documenso/nodemailer-resend": "5.0.0",
"@documenso/tailwind-config": "*",
"@react-email/body": "0.2.0",
"@react-email/button": "0.2.0",
"@react-email/code-block": "0.2.0",
"@react-email/code-inline": "0.0.5",
"@react-email/column": "0.0.13",
"@react-email/container": "0.0.15",
"@react-email/font": "0.0.9",
"@react-email/head": "0.0.12",
"@react-email/heading": "0.0.15",
"@react-email/hr": "0.0.11",
"@react-email/html": "0.0.11",
"@react-email/img": "0.0.11",
"@react-email/link": "0.0.12",
"@react-email/preview": "0.0.13",
"@react-email/render": "2.0.0",
"@react-email/row": "0.0.12",
"@react-email/section": "0.0.16",
"@react-email/tailwind": "^2.0.1",
"@react-email/text": "0.1.5",
"@react-email/render": "2.1.0",
"nodemailer": "^9.0.0",
"react-email": "^5.0.6",
"react-email": "^6.9.0",
"resend": "^6.5.2"
},
"devDependencies": {
@@ -8,7 +8,7 @@ type SaveRequest<T, R> = {
export const useAutoSave = <T, R = void>(onSave: (data: T) => Promise<R>, options: { delay?: number } = {}) => {
const { delay = 2000 } = options;
const saveTimeoutRef = useRef<NodeJS.Timeout>();
const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
const saveQueueRef = useRef<SaveRequest<T, R>[]>([]);
const isProcessingRef = useRef(false);
@@ -15,7 +15,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useLingui } from '@lingui/react/macro';
import { EnvelopeType, Prisma, ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
import type React from 'react';
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
import { createContext, useCallback, useContext, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { useSearchParams } from 'react-router';
import type { TDocumentEmailSettings } from '../../types/document-email';
@@ -107,7 +107,39 @@ export const EnvelopeEditorProvider = ({
const [_searchParams, setSearchParams] = useSearchParams();
const [envelope, _setEnvelope] = useState(initialEnvelope);
/**
* The envelope is kept in a ref-backed external store instead of useState so
* that async consumers (debounced autosave callbacks, flushAutosave, resetForms)
* can synchronously read the latest value via `getEnvelope`.
*
* React subscribes to the store through useSyncExternalStore, keeping renders in
* sync without maintaining a separate copy of the state.
*/
const envelopeStoreRef = useRef(initialEnvelope);
const envelopeStoreSubscribersRef = useRef(new Set<() => void>());
const subscribeToEnvelopeStore = useCallback((onStoreChange: () => void) => {
envelopeStoreSubscribersRef.current.add(onStoreChange);
return () => {
envelopeStoreSubscribersRef.current.delete(onStoreChange);
};
}, []);
const getEnvelope = useCallback(() => envelopeStoreRef.current, []);
const setEnvelope = useCallback((action: React.SetStateAction<TEditorEnvelope>) => {
const next = typeof action === 'function' ? action(envelopeStoreRef.current) : action;
envelopeStoreRef.current = next;
for (const onStoreChange of envelopeStoreSubscribersRef.current) {
onStoreChange();
}
}, []);
const envelope = useSyncExternalStore(subscribeToEnvelopeStore, getEnvelope, getEnvelope);
const [autosaveError, setAutosaveError] = useState<boolean>(false);
const isCscMode = IS_INSTANCE_CSC_MODE();
@@ -135,8 +167,6 @@ export const EnvelopeEditorProvider = ({
};
}, [isCscMode, providedEditorConfig]);
const envelopeRef = useRef(initialEnvelope);
const externalFlushCallbacksRef = useRef<Map<string, () => Promise<void>>>(new Map());
const pendingMutationsRef = useRef<Set<Promise<unknown>>>(new Set());
@@ -156,14 +186,6 @@ export const EnvelopeEditorProvider = ({
});
}, []);
const setEnvelope: typeof _setEnvelope = (action) => {
_setEnvelope((prev) => {
const next = typeof action === 'function' ? action(prev) : action;
envelopeRef.current = next;
return next;
});
};
const isEmbedded = editorConfig.embedded !== undefined;
const editorFields = useEditorFields({
@@ -192,16 +214,18 @@ export const EnvelopeEditorProvider = ({
try {
let recipients: TEditorEnvelope['recipients'] = [];
const currentEnvelope = getEnvelope();
if (!isEmbedded) {
const response = await setRecipientsMutation.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
envelopeId: currentEnvelope.id,
envelopeType: currentEnvelope.type,
recipients: localRecipients,
});
recipients = response.data;
} else {
recipients = mapLocalRecipientsToRecipients({ envelope, localRecipients });
recipients = mapLocalRecipientsToRecipients({ envelope: currentEnvelope, localRecipients });
}
setEnvelope((prev) => ({
@@ -211,9 +235,7 @@ export const EnvelopeEditorProvider = ({
}));
// Reset the local fields to ensure deleted recipient fields are removed.
editorFields.resetForm(
envelope.fields.filter((field) => recipients.some((recipient) => recipient.id === field.recipientId)),
);
editorFields.resetForm(getEnvelope().fields);
setAutosaveError(false);
} catch (err) {
@@ -248,16 +270,18 @@ export const EnvelopeEditorProvider = ({
try {
let fields: TSetEnvelopeFieldsResponse['data'] = [];
const currentEnvelope = getEnvelope();
if (!isEmbedded) {
const response = await setFieldsMutation.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
envelopeId: currentEnvelope.id,
envelopeType: currentEnvelope.type,
fields: localFields,
});
fields = response.data;
} else {
fields = mapLocalFieldsToFields({ envelope, localFields });
fields = mapLocalFieldsToFields({ envelope: currentEnvelope, localFields });
}
setEnvelope((prev) => ({
@@ -309,7 +333,7 @@ export const EnvelopeEditorProvider = ({
try {
const response = !isEmbedded
? await updateEnvelopeMutation.mutateAsync({
envelopeId: envelope.id,
envelopeId: getEnvelope().id,
data,
meta,
})
@@ -467,12 +491,14 @@ export const EnvelopeEditorProvider = ({
};
const resetForms = () => {
const currentEnvelope = getEnvelope();
editorRecipients.resetForm({
recipients: envelopeRef.current.recipients,
documentMeta: envelopeRef.current.documentMeta,
recipients: currentEnvelope.recipients,
documentMeta: currentEnvelope.documentMeta,
});
editorFields.resetForm(envelopeRef.current.fields);
editorFields.resetForm(currentEnvelope.fields);
};
const flushAutosave = async (): Promise<TEditorEnvelope> => {
@@ -488,7 +514,7 @@ export const EnvelopeEditorProvider = ({
await Promise.allSettled(Array.from(pendingMutationsRef.current));
}
return envelopeRef.current;
return getEnvelope();
};
return (
+18
View File
@@ -43,6 +43,20 @@ export enum AppErrorCode {
*/
RECIPIENT_ALREADY_SIGNED = 'RECIPIENT_ALREADY_SIGNED',
/**
* A completion request was made for a recipient that still has required
* fields which have not been inserted. Usually indicates the client's field
* state is out of sync with the server (e.g. a field insert failed to
* persist before submission).
*/
RECIPIENT_HAS_UNSIGNED_FIELDS = 'RECIPIENT_HAS_UNSIGNED_FIELDS',
/**
* A completion request was made by a recipient in a sequential signing flow
* before the preceding recipients have signed.
*/
RECIPIENT_OUT_OF_TURN = 'RECIPIENT_OUT_OF_TURN',
/**
* A signer recipient does not have a signature field assigned. Thrown when
* distributing an envelope or using a direct template where at least one
@@ -99,6 +113,8 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
[AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_TSP_LOCKED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.MISSING_SIGNATURE_FIELD]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.RECIPIENT_OUT_OF_TURN]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_INSTANCE_MODE_MISMATCH]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_UNLICENSED]: { code: 'FORBIDDEN', status: 403 },
[AppErrorCode.CSC_PROVIDER_INFO_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
@@ -307,6 +323,8 @@ export class AppError extends Error {
AppErrorCode.ENVELOPE_LEGACY,
AppErrorCode.ENVELOPE_TSP_LOCKED,
AppErrorCode.MISSING_SIGNATURE_FIELD,
AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS,
AppErrorCode.RECIPIENT_OUT_OF_TURN,
AppErrorCode.CSC_INSTANCE_MODE_MISMATCH,
AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY,
AppErrorCode.CSC_CERT_INVALID,
+5 -5
View File
@@ -15,7 +15,7 @@
"clean": "rimraf node_modules"
},
"dependencies": {
"@ai-sdk/google-vertex": "3.0.81",
"@ai-sdk/google-vertex": "5.0.48",
"@aws-sdk/client-s3": "^3.998.0",
"@aws-sdk/client-sesv2": "^3.998.0",
"@aws-sdk/cloudfront-signer": "^3.998.0",
@@ -29,6 +29,7 @@
"@documenso/email": "*",
"@documenso/prisma": "*",
"@documenso/signing": "*",
"@documenso/skia-canvas": "^3.0.8-documenso.3",
"@lingui/core": "^5.6.0",
"@lingui/macro": "^5.6.0",
"@lingui/react": "^5.6.0",
@@ -42,7 +43,7 @@
"@sindresorhus/slugify": "^3.0.0",
"@team-plain/typescript-sdk": "^5.11.0",
"@vvo/tzdb": "^6.196.0",
"ai": "^5.0.104",
"ai": "^7.0.58",
"bullmq": "^5.71.1",
"colord": "^2.9.3",
"csv-parse": "^6.1.0",
@@ -64,10 +65,9 @@
"postcss-selector-parser": "^7.1.4",
"posthog-js": "^1.297.2",
"posthog-node": "4.18.0",
"react": "^18",
"react": "^19.2.7",
"remeda": "^2.32.0",
"sharp": "0.34.5",
"skia-canvas": "^3.0.8",
"sharp": "0.35.3",
"stripe": "^12.18.0",
"ts-pattern": "^5.9.0",
"zod": "^3.25.76"
+1 -1
View File
@@ -1,6 +1,6 @@
import { Canvas, Image, Path2D } from '@documenso/skia-canvas';
import pMap from 'p-map';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
import { Canvas, Image, Path2D } from 'skia-canvas';
// @ts-expect-error napi-rs/canvas satisfies the requirements
globalThis.Path2D = Path2D;
@@ -59,7 +59,7 @@ export const completeDocumentWithToken = async ({
nextSigner,
recipientOverride,
}: CompleteDocumentWithTokenOptions) => {
const envelope = await prisma.envelope.findFirstOrThrow({
const envelope = await prisma.envelope.findFirst({
where: {
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
recipients: {
@@ -78,10 +78,23 @@ export const completeDocumentWithToken = async ({
},
});
// The most common cause is a stale signing page: the document was deleted,
// or the recipient was removed, after the link was opened. Surface a
// NOT_FOUND instead of leaking a Prisma P2025 as a 500.
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found for the provided signing token',
statusCode: 404,
});
}
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
if (envelope.recipients.length === 0) {
throw new Error(`Document ${envelope.id} has no recipient with token ${token}`);
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Document ${envelope.id} has no recipient with the provided token`,
statusCode: 404,
});
}
const [recipient] = envelope.recipients;
@@ -98,7 +111,19 @@ export const completeDocumentWithToken = async ({
}
if (envelope.status !== DocumentStatus.PENDING) {
throw new Error(`Document ${envelope.id} must be pending`);
const envelopeStatusErrorCode: Record<DocumentStatus, AppErrorCode> = {
[DocumentStatus.DRAFT]: AppErrorCode.ENVELOPE_DRAFT,
[DocumentStatus.COMPLETED]: AppErrorCode.ENVELOPE_COMPLETED,
[DocumentStatus.REJECTED]: AppErrorCode.ENVELOPE_REJECTED,
[DocumentStatus.CANCELLED]: AppErrorCode.ENVELOPE_CANCELLED,
// Unreachable: guarded by the status check above.
[DocumentStatus.PENDING]: AppErrorCode.INVALID_REQUEST,
};
throw new AppError(envelopeStatusErrorCode[envelope.status], {
message: `Document ${envelope.id} must be pending to be completed, found ${envelope.status}`,
statusCode: 400,
});
}
assertRecipientNotExpired(recipient);
@@ -116,7 +141,10 @@ export const completeDocumentWithToken = async ({
});
if (!isRecipientsTurn) {
throw new Error(`Recipient ${recipient.id} attempted to complete the document before it was their turn`);
throw new AppError(AppErrorCode.RECIPIENT_OUT_OF_TURN, {
message: `Recipient ${recipient.id} attempted to complete the document before it was their turn`,
statusCode: 400,
});
}
}
@@ -279,7 +307,10 @@ export const completeDocumentWithToken = async ({
}
if (fieldsContainUnsignedRequiredField(fields)) {
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
throw new AppError(AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS, {
message: `Recipient ${recipient.id} has unsigned fields`,
statusCode: 400,
});
}
await prisma.$transaction(async (tx) => {
@@ -57,7 +57,10 @@ export const UNSAFE_createEnvelopeItems = async ({
flattenForm: envelope.type !== 'TEMPLATE',
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized, {
envelopeId: envelope.id,
fileName: file.name,
});
const { documentData } = await putPdfFileServerSide({
name: file.name,
@@ -85,7 +85,10 @@ export const UNSAFE_replaceEnvelopeItemPdf = async ({
flattenForm: envelope.type !== 'TEMPLATE',
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized, {
envelopeId: envelope.id,
fileName: data.file.name,
});
// Upload the new PDF and get a new DocumentData record.
const { documentData: newDocumentData, filePageCount } = await putPdfFileServerSide({
@@ -2,8 +2,9 @@
* !: This is a workaround to fix the memory leak in the skia-canvas library.
* !: Internals are ported from the original `konva/skia-backend.js` file.
*/
import { Canvas, DOMMatrix, Image, Path2D } from '@documenso/skia-canvas';
import { Konva } from 'konva/lib/_CoreInternals';
import { Canvas, DOMMatrix, Image, Path2D } from 'skia-canvas';
// @ts-expect-error skia-canvas satisfies the requirements
global.DOMMatrix = DOMMatrix;
@@ -37,6 +38,6 @@ Konva.Util.createImageElement = () => {
return node as unknown as HTMLImageElement;
};
Konva._renderBackend = 'skia-canvas';
Konva._renderBackend = '@documenso/skia-canvas';
export default Konva;
@@ -1,8 +1,15 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { type TFieldAndMeta, ZEnvelopeFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { logger } from '@documenso/lib/utils/logger';
import { PDF, rgb } from '@libpdf/core';
import type { FieldType, Recipient } from '@prisma/client';
import { parseFieldMetaFromPlaceholder, parseFieldTypeFromPlaceholder } from './helpers';
import {
parseFieldMetaFromPlaceholder,
parseFieldTypeFromPlaceholder,
parsePlaceholderData,
parseRawFieldMetaFromPlaceholder,
} from './helpers';
const PLACEHOLDER_REGEX = /\{\{([^}]+)\}\}/g;
const DEFAULT_FIELD_HEIGHT_PERCENT = 2;
@@ -61,7 +68,15 @@ export type FieldToCreate = TFieldAndMeta & {
height: number;
};
export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<PlaceholderInfo[]> => {
type ExtractPlaceholdersLogContext = {
envelopeId?: string;
fileName?: string;
};
export const extractPlaceholdersFromPDF = async (
pdf: Buffer,
logContext?: ExtractPlaceholdersLogContext,
): Promise<PlaceholderInfo[]> => {
const pdfDoc = await PDF.load(new Uint8Array(pdf));
const placeholders: PlaceholderInfo[] = [];
@@ -85,7 +100,7 @@ export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<Placehold
continue;
}
const placeholderData = innerMatch[1].split(',').map((property) => property.trim());
const placeholderData = parsePlaceholderData(innerMatch[1]);
const [fieldTypeString, recipientOrMeta, ...fieldMetaData] = placeholderData;
let fieldType: FieldType;
@@ -109,15 +124,52 @@ export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<Placehold
const recipient = recipientOrMeta;
const rawFieldMeta = Object.fromEntries(fieldMetaData.map((property) => property.split('=')));
/*
Parse and validate the field metadata. A malformed selection placeholder
(e.g. an unknown validation rule or a default value that doesn't match an
option) is skipped like an invalid field type rather than aborting the whole
upload, which may contain other valid placeholders and files.
*/
let fieldAndMeta: TFieldAndMeta;
try {
const rawFieldMeta = parseRawFieldMetaFromPlaceholder(fieldMetaData);
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType);
const fieldAndMeta: TFieldAndMeta = ZEnvelopeFieldAndMetaSchema.parse({
const parsedFieldAndMeta = ZEnvelopeFieldAndMetaSchema.safeParse({
type: fieldType,
fieldMeta: parsedFieldMeta,
});
/*
Surface schema failures as INVALID_BODY (400) instead of letting the raw
ZodError bubble up to the caller as an INTERNAL_SERVER_ERROR (500).
*/
if (!parsedFieldAndMeta.success) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid field metadata for placeholder "${placeholder}": ${parsedFieldAndMeta.error.message}`,
});
}
fieldAndMeta = parsedFieldAndMeta.data;
} catch (error) {
const appError = AppError.parseError(error);
logger.warn(
{
envelopeId: logContext?.envelopeId,
fileName: logContext?.fileName,
placeholder,
page: page.index + 1,
code: appError.code,
message: appError.message,
},
'Skipping placeholder with invalid field metadata',
);
continue;
}
/*
LibPDF returns bbox in points with bottom-left origin.
Convert Y to top-left origin for consistency with the rest of the system.
@@ -182,8 +234,9 @@ export const removePlaceholdersFromPDF = async (pdf: Buffer, placeholders?: Plac
*/
export const extractPdfPlaceholders = async (
pdf: Buffer,
logContext?: ExtractPlaceholdersLogContext,
): Promise<{ cleanedPdf: Buffer; placeholders: PlaceholderInfo[] }> => {
const placeholders = await extractPlaceholdersFromPDF(pdf);
const placeholders = await extractPlaceholdersFromPDF(pdf, logContext);
if (placeholders.length === 0) {
return { cleanedPdf: pdf, placeholders: [] };
@@ -0,0 +1,235 @@
import { FieldType } from '@prisma/client';
import { describe, expect, it } from 'vitest';
import { AppError, AppErrorCode } from '../../errors/app-error';
import {
parseFieldMetaFromPlaceholder,
parseFieldTypeFromPlaceholder,
parsePlaceholderData,
parseRawFieldMetaFromPlaceholder,
} from './helpers';
const expectInvalidBody = (fn: () => unknown) => {
try {
fn();
expect.unreachable('Expected an AppError to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(AppError);
expect((error as AppError).code).toBe(AppErrorCode.INVALID_BODY);
}
};
describe('parseFieldTypeFromPlaceholder function', () => {
it('maps known field type strings to the FieldType enum', () => {
expect(parseFieldTypeFromPlaceholder('signature')).toBe(FieldType.SIGNATURE);
expect(parseFieldTypeFromPlaceholder('radio')).toBe(FieldType.RADIO);
expect(parseFieldTypeFromPlaceholder('checkbox')).toBe(FieldType.CHECKBOX);
expect(parseFieldTypeFromPlaceholder('dropdown')).toBe(FieldType.DROPDOWN);
});
it('is case-insensitive and trims surrounding whitespace', () => {
expect(parseFieldTypeFromPlaceholder(' SiGnAtUrE ')).toBe(FieldType.SIGNATURE);
expect(parseFieldTypeFromPlaceholder('RADIO')).toBe(FieldType.RADIO);
});
it('throws INVALID_BODY for an unknown field type', () => {
expectInvalidBody(() => parseFieldTypeFromPlaceholder('FILE'));
});
});
describe('parsePlaceholderData function', () => {
it('splits top-level parts on commas and trims each token', () => {
expect(parsePlaceholderData('SIGNATURE, r1, required=true')).toEqual(['SIGNATURE', 'r1', 'required=true']);
});
it('does not split on escaped commas', () => {
expect(parsePlaceholderData('dropdown, r1, options=Legal\\, Compliance|Sales')).toEqual([
'dropdown',
'r1',
'options=Legal\\, Compliance|Sales',
]);
});
});
describe('parseRawFieldMetaFromPlaceholder function', () => {
it('splits each token into a key/value entry', () => {
expect(parseRawFieldMetaFromPlaceholder(['required=true', 'fontSize=12'])).toEqual({
required: 'true',
fontSize: '12',
});
});
it('only splits on the first unescaped equals sign', () => {
expect(parseRawFieldMetaFromPlaceholder(['label=a=b'])).toEqual({ label: 'a=b' });
});
it('drops tokens without a value and overwrites duplicate keys with the last', () => {
expect(parseRawFieldMetaFromPlaceholder(['required', 'fontSize=12', 'fontSize=14'])).toEqual({
fontSize: '14',
});
});
});
describe('parseFieldMetaFromPlaceholder function', () => {
describe('non-field-meta cases', () => {
it('returns undefined for signature and free signature fields', () => {
expect(parseFieldMetaFromPlaceholder({ required: 'true' }, FieldType.SIGNATURE)).toBeUndefined();
expect(parseFieldMetaFromPlaceholder({}, FieldType.FREE_SIGNATURE)).toBeUndefined();
});
it('returns undefined when there is no metadata', () => {
expect(parseFieldMetaFromPlaceholder({}, FieldType.TEXT)).toBeUndefined();
});
});
describe('generic metadata', () => {
it('coerces required/readOnly to booleans (case-insensitive)', () => {
expect(parseFieldMetaFromPlaceholder({ required: 'TRUE', readOnly: 'false' }, FieldType.TEXT)).toEqual({
type: 'text',
required: true,
readOnly: false,
});
});
it('coerces numeric properties to numbers', () => {
expect(parseFieldMetaFromPlaceholder({ fontSize: '14' }, FieldType.TEXT)).toEqual({
type: 'text',
fontSize: 14,
});
});
it('drops numeric properties that are not a number', () => {
const parsed = parseFieldMetaFromPlaceholder({ fontSize: 'abc' }, FieldType.TEXT);
expect(parsed).toEqual({ type: 'text' });
expect(parsed).not.toHaveProperty('fontSize');
});
it('keeps label/placeholder for non-selection fields', () => {
expect(parseFieldMetaFromPlaceholder({ label: 'Company Name', placeholder: 'Acme' }, FieldType.TEXT)).toEqual({
type: 'text',
label: 'Company Name',
placeholder: 'Acme',
});
});
});
describe('radio fields', () => {
it('builds stable values from options', () => {
expect(parseFieldMetaFromPlaceholder({ options: 'Yes|No|Maybe' }, FieldType.RADIO)).toEqual({
type: 'radio',
values: [
{ id: 1, checked: false, value: 'Yes' },
{ id: 2, checked: false, value: 'No' },
{ id: 3, checked: false, value: 'Maybe' },
],
});
});
it('marks only the selected option as checked', () => {
const parsed = parseFieldMetaFromPlaceholder({ options: 'Yes|No|Maybe', selected: 'No' }, FieldType.RADIO);
expect(parsed).toEqual({
type: 'radio',
values: [
{ id: 1, checked: false, value: 'Yes' },
{ id: 2, checked: true, value: 'No' },
{ id: 3, checked: false, value: 'Maybe' },
],
});
});
it('throws when a default value is provided without options', () => {
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ selected: 'No' }, FieldType.RADIO));
});
it('throws when the default value does not match an option', () => {
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ options: 'Yes|No', selected: 'Maybe' }, FieldType.RADIO));
});
it('throws when options is empty', () => {
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ options: '' }, FieldType.RADIO));
});
});
describe('checkbox fields', () => {
it('builds values with checked state, validation rule alias and length', () => {
const parsed = parseFieldMetaFromPlaceholder(
{
options: 'Email|SMS|Phone',
checked: 'Email|Phone',
validationRule: 'atLeast',
validationLength: '1',
},
FieldType.CHECKBOX,
);
expect(parsed).toEqual({
type: 'checkbox',
validationRule: 'Select at least',
validationLength: 1,
values: [
{ id: 1, checked: true, value: 'Email' },
{ id: 2, checked: false, value: 'SMS' },
{ id: 3, checked: true, value: 'Phone' },
],
});
});
it('throws for an unknown validation rule', () => {
expectInvalidBody(() =>
parseFieldMetaFromPlaceholder({ options: 'A|B', validationRule: 'nope' }, FieldType.CHECKBOX),
);
});
it('throws when checked values are provided without options', () => {
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ checked: 'A' }, FieldType.CHECKBOX));
});
it('throws when a checked value does not match an option', () => {
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ options: 'A|B', checked: 'C' }, FieldType.CHECKBOX));
});
});
describe('dropdown fields', () => {
it('builds values and sets a matching default value', () => {
expect(
parseFieldMetaFromPlaceholder(
{ options: 'United States|Canada|United Kingdom', defaultValue: 'Canada' },
FieldType.DROPDOWN,
),
).toEqual({
type: 'dropdown',
values: [{ value: 'United States' }, { value: 'Canada' }, { value: 'United Kingdom' }],
defaultValue: 'Canada',
});
});
it('throws when the default value does not match an option', () => {
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ options: 'A|B', defaultValue: 'C' }, FieldType.DROPDOWN));
});
});
describe('selection field options parsing', () => {
it('trims option values and drops empty entries', () => {
expect(parseFieldMetaFromPlaceholder({ options: ' A || B ' }, FieldType.DROPDOWN)).toEqual({
type: 'dropdown',
values: [{ value: 'A' }, { value: 'B' }],
});
});
it('parses escaped delimiters through the full placeholder pipeline', () => {
const [, , ...fieldMetaData] = parsePlaceholderData(
'dropdown, r1, options=Sales\\|Ops|Legal\\, Compliance|A\\=B',
);
const rawFieldMeta = parseRawFieldMetaFromPlaceholder(fieldMetaData);
const parsed = parseFieldMetaFromPlaceholder(rawFieldMeta, FieldType.DROPDOWN);
expect(parsed).toEqual({
type: 'dropdown',
values: [{ value: 'Sales|Ops' }, { value: 'Legal, Compliance' }, { value: 'A=B' }],
});
});
});
});
+312 -6
View File
@@ -1,8 +1,8 @@
import path from 'node:path';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FontLibrary } from '@documenso/skia-canvas';
import type { Recipient } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { FontLibrary } from 'skia-canvas';
import { match } from 'ts-pattern';
/**
@@ -45,6 +45,134 @@ type RecipientPlaceholderInfo = {
recipientIndex: number;
};
const CHECKBOX_VALIDATION_RULE_BY_ALIAS: Record<string, string> = {
atLeast: 'Select at least',
exactly: 'Select exactly',
atMost: 'Select at most',
};
/*
Split a string on a delimiter, treating `\` as an escape for the next character.
Delimiters preceded by `\` are kept in the output instead of splitting (e.g. `\,`, `\=`, `\|`).
With delimiter ',' (top-level placeholder parts):
'radio, r1, options=Card/Check|Bank Transfer, selected=Bank Transfer'
-> ['radio', ' r1', ' options=Card/Check|Bank Transfer', ' selected=Bank Transfer']
With delimiter '=' (split one field metadata token into key + value):
'options=Card/Check|Bank Transfer'
-> ['options', 'Card/Check|Bank Transfer']
With delimiter '|' (split option list inside 'options='):
'Card/Check|Bank Transfer'
-> ['Card/Check', 'Bank Transfer']
*/
const splitPlaceholderToken = (value: string, delimiter: string): string[] => {
const parts: string[] = [];
let currentPart = '';
for (let index = 0; index < value.length; index++) {
const char = value[index];
const nextChar = value[index + 1];
if (char === '\\' && nextChar) {
currentPart += char + nextChar;
index++;
continue;
}
if (char === delimiter) {
parts.push(currentPart);
currentPart = '';
continue;
}
currentPart += char;
}
parts.push(currentPart);
return parts;
};
/*
Removes the escape backslashes left over after splitting, so \,=, \|, \\ become their literal characters.
E.g.
'Legal\, Compliance' -> 'Legal, Compliance'
'Card\|Check' -> 'Card|Check'
'A\=B' -> 'A=B'
'C\D' -> 'C\D'
*/
const unescapePlaceholderValue = (value: string): string => {
return value.replace(/\\([,=|\\])/g, '$1');
};
/*
Cleans up a selection option/default after splitting:
unescapes literal delimiters, collapses repeated whitespace, and trims the ends.
E.g.
' Legal\, Compliance ' -> 'Legal, Compliance'
*/
const normalizePlaceholderSelectionValue = (value: string): string => {
return unescapePlaceholderValue(value).replace(/\s+/g, ' ').trim();
};
/*
Split an options string into individual choices.
Splits on unescaped '|', then unescapes, trims, and drops empty entries.
E.g.
'Card/Check|Bank Transfer' -> ['Card/Check', 'Bank Transfer']
'Card\\|Check|Bank Transfer' -> ['Card|Check', 'Bank Transfer']
*/
const parsePlaceholderOptions = (value: string): string[] => {
return splitPlaceholderToken(value, '|')
.map((option) => normalizePlaceholderSelectionValue(option))
.filter((option) => option.length > 0);
};
/*
Split a placeholder string into top-level parts (field type, recipient, metadata).
Splits on unescaped commas, then trims whitespace.
E.g.
'SIGNATURE, r1, required=true'
-> ['SIGNATURE', 'r1', 'required=true']
*/
export const parsePlaceholderData = (value: string): string[] => {
return splitPlaceholderToken(value, ',').map((token) => token.trim());
};
/*
Transforms the field metadata string array into a record of key/value pairs.
Each token is split on the first unescaped '='; tokens with no key or no '=' are dropped.
E.g.
['required=true', 'fontSize=12', 'label=a=b']
-> { required: 'true', fontSize: '12', label: 'a=b' }
*/
export const parseRawFieldMetaFromPlaceholder = (fieldMetaData: string[]): Record<string, string> => {
const rawFieldMeta: Record<string, string> = {};
for (const fieldMeta of fieldMetaData) {
// Split on the first '=' only; any further '=' stays part of the value (e.g. 'label=a=b').
const [rawKey, ...valueParts] = splitPlaceholderToken(fieldMeta, '=');
if (!rawKey || valueParts.length === 0) {
continue;
}
const key = rawKey.trim();
const value = valueParts.join('=').trim();
rawFieldMeta[key] = value;
}
return rawFieldMeta;
};
/*
Parse field type string to FieldType enum.
Normalizes the input (uppercase, trim) and validates it's a valid field type.
@@ -72,6 +200,169 @@ export const parseFieldTypeFromPlaceholder = (fieldTypeString: string): FieldTyp
});
};
const getDefaultFieldMetaValue = (rawFieldMeta: Record<string, string>) => {
const defaultValue = rawFieldMeta.defaultValue ?? rawFieldMeta.default ?? rawFieldMeta.selected;
return defaultValue ? normalizePlaceholderSelectionValue(defaultValue) : undefined;
};
const parseCheckboxValidationRule = (value: string): string => {
const validationRule = CHECKBOX_VALIDATION_RULE_BY_ALIAS[value];
if (!validationRule) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid checkbox placeholder validation rule: ${value}`,
});
}
return validationRule;
};
const parseSelectionFieldOptions = (
rawFieldMeta: Record<string, string>,
fieldType: FieldType,
): string[] | undefined => {
const rawOptions = rawFieldMeta.options;
if (rawOptions === undefined) {
return;
}
const parsedOptions = parsePlaceholderOptions(rawOptions);
if (parsedOptions.length === 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `${fieldType} placeholder options must contain at least one value`,
});
}
return parsedOptions;
};
const applyRadioFieldOptions = (parsedFieldMeta: Record<string, unknown>, rawFieldMeta: Record<string, string>) => {
const options = parseSelectionFieldOptions(rawFieldMeta, FieldType.RADIO);
const defaultValue = getDefaultFieldMetaValue(rawFieldMeta);
if (!options && defaultValue) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Radio placeholder default value requires options',
});
}
if (!options) {
return;
}
const selectedOptionIndex = defaultValue ? options.findIndex((option) => option === defaultValue) : -1;
if (defaultValue && selectedOptionIndex === -1) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Radio placeholder default value "${defaultValue}" must match one of the options`,
});
}
parsedFieldMeta.values = options.map((option, index) => ({
id: index + 1,
checked: index === selectedOptionIndex,
value: option,
}));
};
const applyCheckboxFieldOptions = (parsedFieldMeta: Record<string, unknown>, rawFieldMeta: Record<string, string>) => {
const options = parseSelectionFieldOptions(rawFieldMeta, FieldType.CHECKBOX);
const checkedValues = rawFieldMeta.checked ? parsePlaceholderOptions(rawFieldMeta.checked) : [];
if (!options && checkedValues.length > 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Checkbox placeholder checked values require options',
});
}
if (!options) {
return;
}
const unmatchedCheckedValues = checkedValues.filter((checkedValue) => !options.includes(checkedValue));
if (unmatchedCheckedValues.length > 0) {
const unmatchedCheckedValue = unmatchedCheckedValues[0];
throw new AppError(AppErrorCode.INVALID_BODY, {
message: [`Checkbox placeholder checked value "${unmatchedCheckedValue}"`, 'must match one of the options'].join(
' ',
),
});
}
parsedFieldMeta.values = options.map((option, index) => ({
id: index + 1,
checked: checkedValues.includes(option),
value: option,
}));
};
const applyDropdownFieldOptions = (parsedFieldMeta: Record<string, unknown>, rawFieldMeta: Record<string, string>) => {
const options = parseSelectionFieldOptions(rawFieldMeta, FieldType.DROPDOWN);
const defaultValue = getDefaultFieldMetaValue(rawFieldMeta);
if (!options && defaultValue) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Dropdown placeholder default value requires options',
});
}
if (!options) {
return;
}
if (defaultValue && !options.includes(defaultValue)) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Dropdown placeholder default value "${defaultValue}" must match one of the options`,
});
}
parsedFieldMeta.values = options.map((option) => ({
value: option,
}));
if (defaultValue) {
parsedFieldMeta.defaultValue = defaultValue;
}
};
/*
Generic field metadata properties are simple properties consisting of a key and a value.
E.g. 'required=true', 'fontSize=12', 'textAlign=left'
They don't require special handling.
Special field metadata properties are complex properties consisting of a key and a value with multiple parts.
E.g. 'options=Card/Check|Bank Transfer', 'checked=Card|Check', 'selected=Bank Transfer'
They require special handling.
*/
const shouldSkipGenericFieldMetaParsing = (property: string, fieldType: FieldType): boolean => {
if (property === 'options' || property === 'default' || property === 'selected') {
return true;
}
const isSelectionField =
fieldType === FieldType.CHECKBOX || fieldType === FieldType.RADIO || fieldType === FieldType.DROPDOWN;
if (!isSelectionField) {
return false;
}
if (
property === 'label' ||
property === 'placeholder' ||
property === 'defaultValue' ||
(fieldType === FieldType.CHECKBOX && property === 'checked')
) {
return true;
}
return false;
};
/*
Transform raw field metadata from placeholder format to schema format.
Users should provide properly capitalized property names (e.g., readOnly, fontSize, textAlign).
@@ -91,7 +382,7 @@ export const parseFieldMetaFromPlaceholder = (
const fieldTypeString = String(fieldType).toLowerCase();
const parsedFieldMeta: Record<string, boolean | number | string> = {
const parsedFieldMeta: Record<string, unknown> = {
type: fieldTypeString,
};
@@ -104,24 +395,39 @@ export const parseFieldMetaFromPlaceholder = (
const rawFieldMetaEntries = Object.entries(rawFieldMeta);
for (const [property, value] of rawFieldMetaEntries) {
if (shouldSkipGenericFieldMetaParsing(property, fieldType)) {
continue;
}
const unescapedValue = unescapePlaceholderValue(value);
if (property === 'readOnly' || property === 'required') {
parsedFieldMeta[property] = value === 'true';
parsedFieldMeta[property] = unescapedValue.toLowerCase() === 'true';
} else if (property === 'validationRule' && fieldType === FieldType.CHECKBOX) {
parsedFieldMeta[property] = parseCheckboxValidationRule(unescapedValue);
} else if (
property === 'fontSize' ||
property === 'maxValue' ||
property === 'minValue' ||
property === 'characterLimit'
property === 'characterLimit' ||
property === 'validationLength'
) {
const numValue = Number(value);
const numValue = Number(unescapedValue);
if (!Number.isNaN(numValue)) {
parsedFieldMeta[property] = numValue;
}
} else {
parsedFieldMeta[property] = value;
parsedFieldMeta[property] = unescapedValue;
}
}
match(fieldType)
.with(FieldType.RADIO, () => applyRadioFieldOptions(parsedFieldMeta, rawFieldMeta))
.with(FieldType.CHECKBOX, () => applyCheckboxFieldOptions(parsedFieldMeta, rawFieldMeta))
.with(FieldType.DROPDOWN, () => applyDropdownFieldOptions(parsedFieldMeta, rawFieldMeta))
.otherwise(() => undefined);
return parsedFieldMeta;
};
@@ -2,8 +2,8 @@
import '../konva/skia-backend';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import type { Canvas } from '@documenso/skia-canvas';
import Konva from 'konva';
import type { Canvas } from 'skia-canvas';
import { renderField } from '../../universal/field-renderer/render-field';
import { ensureFontLibrary } from './helpers';
@@ -1,14 +1,16 @@
// sort-imports-ignore
import '../konva/skia-backend';
import fs from 'node:fs';
import path from 'node:path';
import type { Canvas } from '@documenso/skia-canvas';
import { Image as SkiaImage } from '@documenso/skia-canvas';
import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import type { DocumentMeta, Envelope, RecipientRole } from '@prisma/client';
import Konva from 'konva';
import 'konva/skia-backend';
import fs from 'node:fs';
import path from 'node:path';
import type { DateTimeFormatOptions } from 'luxon';
import { DateTime } from 'luxon';
import type { Canvas } from 'skia-canvas';
import { Image as SkiaImage } from 'skia-canvas';
import { match, P } from 'ts-pattern';
import { UAParser } from 'ua-parser-js';
@@ -1,14 +1,16 @@
// sort-imports-ignore
import '../konva/skia-backend';
import fs from 'node:fs';
import path from 'node:path';
import type { Canvas } from '@documenso/skia-canvas';
import { Image as SkiaImage } from '@documenso/skia-canvas';
import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import type { Field, RecipientRole, Signature } from '@prisma/client';
import { SigningStatus } from '@prisma/client';
import Konva from 'konva';
import 'konva/skia-backend';
import fs from 'node:fs';
import path from 'node:path';
import { DateTime } from 'luxon';
import type { Canvas } from 'skia-canvas';
import { Image as SkiaImage } from 'skia-canvas';
import { UAParser } from 'ua-parser-js';
import { renderSVG } from 'uqr';
@@ -14,7 +14,7 @@ let SkiaImage: any;
void (async () => {
if (typeof window === 'undefined') {
const mod = await import('skia-canvas');
const mod = await import('@documenso/skia-canvas');
SkiaImage = mod.Image;
}
})();
+3 -3
View File
@@ -12,9 +12,9 @@
"@documenso/prisma": "*",
"@simplewebauthn/server": "^13.2.2",
"@tanstack/react-query": "5.90.10",
"@trpc/client": "11.8.1",
"@trpc/react-query": "11.8.1",
"@trpc/server": "11.8.1",
"@trpc/client": "11.17.0",
"@trpc/react-query": "11.17.0",
"@trpc/server": "11.17.0",
"@ts-rest/core": "^3.52.1",
"formidable": "^3.5.4",
"luxon": "^3.7.2",
@@ -8,13 +8,14 @@ export const cancelEnvelopeMeta: TrpcRouteMeta = {
method: 'POST',
path: '/envelope/cancel',
summary: 'Cancel envelope',
description: 'Cancel a pending envelope',
tags: ['Envelope'],
},
};
export const ZCancelEnvelopeRequestSchema = z.object({
envelopeId: z.string(),
reason: z.string().optional(),
envelopeId: z.string().describe('The ID of the envelope to cancel.'),
reason: z.string().describe('The reason for cancelling the envelope.').optional(),
});
export const ZCancelEnvelopeResponseSchema = ZSuccessResponseSchema;
@@ -124,7 +124,9 @@ export const createEnvelopeRouteCaller = async ({
});
// Todo: Embeds - Might need to add this for client-side embeds in the future.
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized, {
fileName: file.name,
});
const { documentData } = await putPdfFileServerSide({
name: file.name,
@@ -8,12 +8,13 @@ export const deleteEnvelopeMeta: TrpcRouteMeta = {
method: 'POST',
path: '/envelope/delete',
summary: 'Delete envelope',
description: 'Delete an envelope',
tags: ['Envelope'],
},
};
export const ZDeleteEnvelopeRequestSchema = z.object({
envelopeId: z.string(),
envelopeId: z.string().describe('The ID of the envelope to delete.'),
});
export const ZDeleteEnvelopeResponseSchema = ZSuccessResponseSchema;
@@ -12,24 +12,32 @@ export const updateEnvelopeMeta: TrpcRouteMeta = {
method: 'POST',
path: '/envelope/update',
summary: 'Update envelope',
description: 'Update envelope properties and settings',
tags: ['Envelope'],
},
};
export const ZUpdateEnvelopeRequestSchema = z.object({
envelopeId: z.string(),
envelopeId: z.string().describe('The ID of the envelope to update.'),
data: z
.object({
title: ZDocumentTitleSchema.optional(),
externalId: ZDocumentExternalIdSchema.nullish(),
visibility: ZDocumentVisibilitySchema.optional(),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
folderId: z.string().nullish(),
templateType: z.nativeEnum(TemplateType).optional(),
})
globalAccessAuth: z
.array(ZDocumentAccessAuthTypesSchema)
.describe('The authentication methods required to access the envelope.')
.optional(),
meta: ZDocumentMetaUpdateSchema.optional(),
globalActionAuth: z
.array(ZDocumentActionAuthTypesSchema)
.describe('The authentication methods required to sign the envelope.')
.optional(),
folderId: z.string().describe('The ID of the folder containing the envelope.').nullish(),
templateType: z.nativeEnum(TemplateType).describe('The template type.').optional(),
})
.describe('The envelope properties to update.')
.optional(),
meta: ZDocumentMetaUpdateSchema.describe('The email and signing settings to update.').optional(),
});
export const ZUpdateEnvelopeResponseSchema = ZEnvelopeLiteSchema;
@@ -603,7 +603,7 @@ export const recipientRouter = router({
// can't complete via this route — they go through the CSC sync sign
// flow (`enterprise.csc.signEnvelope`). This route returns the redirect URL
// for the credential-scope OAuth round-trip.
const envelope = await prisma.envelope.findFirstOrThrow({
const envelope = await prisma.envelope.findFirst({
where: {
...unsafeBuildEnvelopeIdQuery({ type: 'documentId', id: documentId }, EnvelopeType.DOCUMENT),
recipients: { some: { token } },
@@ -611,6 +611,16 @@ export const recipientRouter = router({
select: { signatureLevel: true, internalVersion: true },
});
// The most common cause is a stale signing page: the document was
// deleted, or the recipient was removed, after the link was opened.
// Surface a NOT_FOUND instead of leaking a Prisma P2025 as a 500.
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found for the provided signing token',
statusCode: 404,
});
}
if (isTspEnvelope(envelope)) {
return await prepareCscRecipientSigning({
recipientToken: token,
@@ -2,16 +2,27 @@ import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-c
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { Trans, useLingui } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client';
import { SigningStatus } from '@prisma/client';
import { ClockIcon, EyeOffIcon, LockIcon } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { FieldType, SigningStatus } from '@prisma/client';
import {
CalendarDaysIcon,
CheckSquareIcon,
ChevronDownIcon,
ContactIcon,
DiscIcon,
EyeOffIcon,
HashIcon,
LockIcon,
MailIcon,
TypeIcon,
UserIcon,
} from 'lucide-react';
import { type ElementType, useCallback, useEffect, useState } from 'react';
import { isTemplateRecipientEmailPlaceholder } from '../../../lib/constants/template';
import { extractInitials } from '../../../lib/utils/recipient-formatter';
import { SignatureIcon } from '../../icons/signature';
import { cn } from '../../lib/utils';
import { Avatar, AvatarFallback } from '../../primitives/avatar';
import { Badge } from '../../primitives/badge';
import { FRIENDLY_FIELD_TYPE } from '../../primitives/document-flow/types';
import { PopoverHover } from '../../primitives/popover';
@@ -27,16 +38,18 @@ interface EnvelopeRecipientFieldTooltipProps {
showRecipientColors?: boolean;
}
const getRecipientDisplayText = (recipient: { name: string; email: string }) => {
if (recipient.name && !isTemplateRecipientEmailPlaceholder(recipient.email)) {
return `${recipient.name} (${recipient.email})`;
}
if (recipient.name && isTemplateRecipientEmailPlaceholder(recipient.email)) {
return recipient.name;
}
return recipient.email;
const FIELD_TYPE_ICONS: Record<FieldType, ElementType> = {
[FieldType.SIGNATURE]: SignatureIcon,
[FieldType.FREE_SIGNATURE]: SignatureIcon,
[FieldType.INITIALS]: ContactIcon,
[FieldType.TEXT]: TypeIcon,
[FieldType.DATE]: CalendarDaysIcon,
[FieldType.EMAIL]: MailIcon,
[FieldType.NAME]: UserIcon,
[FieldType.NUMBER]: HashIcon,
[FieldType.RADIO]: DiscIcon,
[FieldType.CHECKBOX]: CheckSquareIcon,
[FieldType.DROPDOWN]: ChevronDownIcon,
};
/**
@@ -50,6 +63,8 @@ export function EnvelopeRecipientFieldTooltip({
}: EnvelopeRecipientFieldTooltipProps) {
const { t } = useLingui();
const FieldIcon = FIELD_TYPE_ICONS[field.type];
const [hideField, setHideField] = useState<boolean>(!showRecipientTooltip);
const [coords, setCoords] = useState({
@@ -138,54 +153,64 @@ export function EnvelopeRecipientFieldTooltip({
</Avatar>
}
contentProps={{
className: 'relative flex mb-4 w-fit flex-col p-4 text-sm',
className: 'flex w-64 flex-col overflow-hidden p-0 text-sm',
sideOffset: 20,
onOpenAutoFocus: (event) => event.preventDefault(),
}}
>
<div className="flex items-center gap-2 p-3">
<FieldIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
<p className="min-w-0 flex-1 truncate font-medium">
<Trans>{t(FRIENDLY_FIELD_TYPE[field.type])} field</Trans>
</p>
{showFieldStatus && (
<Badge
className="mx-auto mb-1 py-0.5"
variant={
field?.fieldMeta?.readOnly
? 'neutral'
: field.recipient.signingStatus === SigningStatus.SIGNED
? 'default'
: 'secondary'
}
>
<div className="flex shrink-0 items-center gap-1.5 text-xs">
{field?.fieldMeta?.readOnly ? (
<>
<LockIcon className="mr-1 h-3 w-3" />
<LockIcon className="h-3 w-3 text-muted-foreground" />
<span className="text-muted-foreground">
<Trans>Read Only</Trans>
</span>
</>
) : field.recipient.signingStatus === SigningStatus.SIGNED ? (
<>
<SignatureIcon className="mr-1 h-3 w-3" />
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
<span className="text-green-600 dark:text-green-400">
<Trans>Signed</Trans>
</span>
</>
) : (
<>
<ClockIcon className="mr-1 h-3 w-3" />
<span className="h-1.5 w-1.5 rounded-full bg-amber-400" />
<span className="text-amber-600 dark:text-amber-400">
<Trans>Pending</Trans>
</span>
</>
)}
</Badge>
</div>
)}
</div>
<p className="text-center font-semibold">
<span>
<Trans>{t(FRIENDLY_FIELD_TYPE[field.type])} field</Trans>
</span>
</p>
<div className="flex items-center gap-3 border-border/50 border-t bg-muted/50 px-3 py-2.5">
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-xs">{field.recipient.name || field.recipient.email}</p>
<p className="mt-1 text-center text-muted-foreground text-xs">{getRecipientDisplayText(field.recipient)}</p>
{!isTemplateRecipientEmailPlaceholder(field.recipient.email) && field.recipient.name && (
<p className="truncate text-muted-foreground text-xs">{field.recipient.email}</p>
)}
</div>
<button
className="absolute top-0 right-0 my-1 p-2 focus:outline-none focus-visible:ring-0"
type="button"
className="-m-1 shrink-0 rounded-sm p-1 text-muted-foreground hover:bg-background hover:text-foreground"
onClick={() => setHideField(true)}
title="Hide field"
title={t`Hide field`}
>
<EyeOffIcon className="h-3 w-3" />
<EyeOffIcon className="h-3.5 w-3.5" />
</button>
</div>
</PopoverHover>
</div>
);
+1 -1
View File
@@ -35,7 +35,7 @@ export const SigningCard3D = ({ className, name, signature, signingCelebrationIm
const [trackMouse, setTrackMouse] = useState(false);
const timeoutRef = useRef<number | undefined>();
const timeoutRef = useRef<number | undefined>(undefined);
const cardX = useMotionValue(0);
const cardY = useMotionValue(0);
+7 -34
View File
@@ -18,59 +18,32 @@
"@documenso/tailwind-config": "*",
"@documenso/tsconfig": "*",
"@types/luxon": "^3.7.1",
"@types/react": "18.3.27",
"@types/react-dom": "^18",
"react": "^18",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"react": "^19.2.7",
"typescript": "5.6.2"
},
"dependencies": {
"@documenso/lib": "*",
"@hello-pangea/dnd": "^16.6.0",
"@hello-pangea/dnd": "^18.0.1",
"@hookform/resolvers": "^3",
"@lingui/macro": "^5.6.0",
"@lingui/react": "^5.6.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@scure/base": "^1.2.6",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^1.2.1",
"cmdk": "^0.2.1",
"cmdk": "^1.1.1",
"colord": "^2.9.3",
"framer-motion": "^12.43.0",
"lucide-react": "^0.554.0",
"luxon": "^3.7.2",
"pdfjs-dist": "5.4.296",
"perfect-freehand": "^1.2.2",
"react": "^18",
"react": "^19.2.7",
"react-colorful": "^5.6.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18",
"react-dom": "^19.2.7",
"react-hook-form": "^7.66.1",
"react-rnd": "^10.5.2",
"remeda": "^2.32.0",
+3 -1
View File
@@ -145,7 +145,9 @@ const CommandItem = React.forwardRef<
<CommandPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
// cmdk 1.x always renders data-disabled="true|false", so the variant must
// check the value (bare data-[disabled] matches attribute presence).
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
className,
)}
{...props}
@@ -103,15 +103,10 @@ export const FieldContent = ({ field, documentMeta }: FieldIconProps) => {
) {
return (
<div className="flex flex-col gap-y-2 py-0.5">
<RadioGroup className="gap-y-1">
<RadioGroup value={field.customText ?? ''} className="gap-y-1">
{field.fieldMeta.values.map((item, index) => (
<div key={index} className="flex items-center">
<RadioGroupItem
className="pointer-events-none h-3 w-3"
value={item.value}
id={`option-${index}`}
checked={item.value === field.customText}
/>
<RadioGroupItem className="pointer-events-none h-3 w-3" value={item.value} id={`option-${index}`} />
{item.value && (
<Label htmlFor={`option-${index}`} className="ml-1.5 font-normal text-foreground text-xs">
{item.value}
@@ -1,5 +1,6 @@
import type { RecipientRole } from '@prisma/client';
import { BadgeCheck, Copy, Eye, PencilLine, User } from 'lucide-react';
import type { JSX } from 'react';
export const ROLE_ICONS: Record<RecipientRole, JSX.Element> = {
SIGNER: <PencilLine className="h-4 w-4" />,
@@ -13,7 +13,7 @@ import { getSvgPathFromStroke } from './helper';
import { Point } from './point';
import { SignaturePadColorPicker } from './signature-pad-color-picker';
const checkSignatureValidity = (element: RefObject<HTMLCanvasElement>) => {
const checkSignatureValidity = (element: RefObject<HTMLCanvasElement | null>) => {
if (!element.current) {
return false;
}
File diff suppressed because one or more lines are too long
-73
View File
@@ -1,73 +0,0 @@
#!/usr/bin/env node
import { mkdirSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import { generateId } from './utils/generate-id';
const JUSTIFICATIONS_DIR = join(process.cwd(), '.agents', 'justifications');
const main = () => {
const args = process.argv.slice(2);
if (args.length === 0) {
console.error('Usage: npx tsx scripts/create-justification.ts "file-slug" [content]');
console.error(' or: npx tsx scripts/create-justification.ts "file-slug" << HEREDOC');
process.exit(1);
}
const slug = args[0];
let content = '';
// Check if content is provided as second argument
if (args.length > 1) {
content = args.slice(1).join(' ');
} else {
// Read from stdin (heredoc)
try {
const stdin = readFileSync(0, 'utf-8');
content = stdin.trim();
} catch (error) {
console.error('Error reading from stdin:', error);
process.exit(1);
}
}
if (!content) {
console.error('Error: No content provided');
process.exit(1);
}
// Generate unique ID
const id = generateId();
const filename = `${id}-${slug}.md`;
const filepath = join(JUSTIFICATIONS_DIR, filename);
// Format title from slug (kebab-case to Title Case)
const title = slug
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
// Get current date in ISO format
const date = new Date().toISOString().split('T')[0];
// Create frontmatter
const frontmatter = `---
date: ${date}
title: ${title}
---
`;
// Ensure directory exists
mkdirSync(JUSTIFICATIONS_DIR, { recursive: true });
// Write file with frontmatter
writeFileSync(filepath, frontmatter + content, 'utf-8');
console.log(`Created justification: ${filepath}`);
console.log(`ID: ${id}`);
console.log(`Filename: ${filename}`);
};
main();
-73
View File
@@ -1,73 +0,0 @@
#!/usr/bin/env node
import { mkdirSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import { generateId } from './utils/generate-id';
const PLANS_DIR = join(process.cwd(), '.agents', 'plans');
const main = () => {
const args = process.argv.slice(2);
if (args.length === 0) {
console.error('Usage: npx tsx scripts/create-plan.ts "file-slug" [content]');
console.error(' or: npx tsx scripts/create-plan.ts "file-slug" << HEREDOC');
process.exit(1);
}
const slug = args[0];
let content = '';
// Check if content is provided as second argument
if (args.length > 1) {
content = args.slice(1).join(' ');
} else {
// Read from stdin (heredoc)
try {
const stdin = readFileSync(0, 'utf-8');
content = stdin.trim();
} catch (error) {
console.error('Error reading from stdin:', error);
process.exit(1);
}
}
if (!content) {
console.error('Error: No content provided');
process.exit(1);
}
// Generate unique ID
const id = generateId();
const filename = `${id}-${slug}.md`;
const filepath = join(PLANS_DIR, filename);
// Format title from slug (kebab-case to Title Case)
const title = slug
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
// Get current date in ISO format
const date = new Date().toISOString().split('T')[0];
// Create frontmatter
const frontmatter = `---
date: ${date}
title: ${title}
---
`;
// Ensure directory exists
mkdirSync(PLANS_DIR, { recursive: true });
// Write file with frontmatter
writeFileSync(filepath, frontmatter + content, 'utf-8');
console.log(`Created plan: ${filepath}`);
console.log(`ID: ${id}`);
console.log(`Filename: ${filename}`);
};
main();
-73
View File
@@ -1,73 +0,0 @@
#!/usr/bin/env node
import { mkdirSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import { generateId } from './utils/generate-id';
const SCRATCHES_DIR = join(process.cwd(), '.agents', 'scratches');
const main = () => {
const args = process.argv.slice(2);
if (args.length === 0) {
console.error('Usage: npx tsx scripts/create-scratch.ts "file-slug" [content]');
console.error(' or: npx tsx scripts/create-scratch.ts "file-slug" << HEREDOC');
process.exit(1);
}
const slug = args[0];
let content = '';
// Check if content is provided as second argument
if (args.length > 1) {
content = args.slice(1).join(' ');
} else {
// Read from stdin (heredoc)
try {
const stdin = readFileSync(0, 'utf-8');
content = stdin.trim();
} catch (error) {
console.error('Error reading from stdin:', error);
process.exit(1);
}
}
if (!content) {
console.error('Error: No content provided');
process.exit(1);
}
// Generate unique ID
const id = generateId();
const filename = `${id}-${slug}.md`;
const filepath = join(SCRATCHES_DIR, filename);
// Format title from slug (kebab-case to Title Case)
const title = slug
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
// Get current date in ISO format
const date = new Date().toISOString().split('T')[0];
// Create frontmatter
const frontmatter = `---
date: ${date}
title: ${title}
---
`;
// Ensure directory exists
mkdirSync(SCRATCHES_DIR, { recursive: true });
// Write file with frontmatter
writeFileSync(filepath, frontmatter + content, 'utf-8');
console.log(`Created scratch: ${filepath}`);
console.log(`ID: ${id}`);
console.log(`Filename: ${filename}`);
};
main();
-84
View File
@@ -1,84 +0,0 @@
/**
* Generates a unique identifier using three simple words.
* Falls back to unix timestamp if word generation fails.
*/
export const generateId = (): string => {
const adjectives = [
'happy',
'bright',
'swift',
'calm',
'bold',
'clever',
'gentle',
'quick',
'sharp',
'warm',
'cool',
'fresh',
'solid',
'clear',
'sweet',
'wild',
'quiet',
'loud',
'smooth',
];
const nouns = [
'moon',
'star',
'ocean',
'river',
'forest',
'mountain',
'cloud',
'wave',
'stone',
'flower',
'bird',
'wind',
'light',
'shadow',
'fire',
'earth',
'sky',
'tree',
'leaf',
'rock',
];
const colors = [
'blue',
'red',
'green',
'yellow',
'purple',
'orange',
'pink',
'cyan',
'amber',
'emerald',
'violet',
'indigo',
'coral',
'teal',
'gold',
'silver',
'copper',
'bronze',
'ivory',
'jade',
];
try {
const randomAdjective = adjectives[Math.floor(Math.random() * adjectives.length)];
const randomColor = colors[Math.floor(Math.random() * colors.length)];
const randomNoun = nouns[Math.floor(Math.random() * nouns.length)];
return `${randomAdjective}-${randomColor}-${randomNoun}`;
} catch {
// Fallback to unix timestamp if something goes wrong
return Date.now().toString();
}
};