diff --git a/.agents/skills/create-justification/SKILL.md b/.agents/skills/create-justification/SKILL.md deleted file mode 100644 index 78a2aaea9..000000000 --- a/.agents/skills/create-justification/SKILL.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -name: create-justification -description: Create a new justification file in .agents/justifications/ with a unique three-word ID, frontmatter, and formatted title -license: MIT -compatibility: opencode -metadata: - audience: agents - workflow: decision-making ---- - -## What I do - -I help you create new justification files in the `.agents/justifications/` directory. Each justification file gets: - -- A unique three-word identifier (e.g., `swift-emerald-river`) -- Frontmatter with the current date and formatted title -- Content you provide - -## How to use - -Run the script with a slug and content: - -```bash -npx tsx scripts/create-justification.ts "decision-name" "Justification content here" -``` - -Or use heredoc for multi-line content: - -```bash -npx tsx scripts/create-justification.ts "decision-name" << HEREDOC -Multi-line -justification content -goes here -HEREDOC -``` - -## File format - -Files are created as: `{three-word-id}-{slug}.md` - -Example: `swift-emerald-river-decision-name.md` - -The file includes frontmatter: - -```markdown ---- -date: 2026-01-13 -title: Decision Name ---- - -Your content here -``` - -## When to use me - -Use this skill when you need to document the reasoning or justification for a decision, approach, or architectural choice. The unique ID ensures no filename conflicts, and the frontmatter provides metadata for organization. diff --git a/.agents/skills/create-plan/SKILL.md b/.agents/skills/create-plan/SKILL.md deleted file mode 100644 index 8ceb2ef8c..000000000 --- a/.agents/skills/create-plan/SKILL.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -name: create-plan -description: Create a new plan file in .agents/plans/ with a unique three-word ID, frontmatter, and formatted title -license: MIT -compatibility: opencode -metadata: - audience: agents - workflow: planning ---- - -## What I do - -I help you create new plan files in the `.agents/plans/` directory. Each plan file gets: - -- A unique three-word identifier (e.g., `happy-blue-moon`) -- Frontmatter with the current date and formatted title -- Content you provide - -## How to use - -Run the script with a slug and content: - -```bash -npx tsx scripts/create-plan.ts "feature-name" "Plan content here" -``` - -Or use heredoc for multi-line content: - -```bash -npx tsx scripts/create-plan.ts "feature-name" << HEREDOC -Multi-line -plan content -goes here -HEREDOC -``` - -## File format - -Files are created as: `{three-word-id}-{slug}.md` - -Example: `happy-blue-moon-feature-name.md` - -The file includes frontmatter: - -```markdown ---- -date: 2026-01-13 -title: Feature Name ---- - -Your content here -``` - -## When to use me - -Use this skill when you need to create a new plan document for a feature, task, or project. The unique ID ensures no filename conflicts, and the frontmatter provides metadata for organization. diff --git a/.agents/skills/create-scratch/SKILL.md b/.agents/skills/create-scratch/SKILL.md deleted file mode 100644 index e44e4779d..000000000 --- a/.agents/skills/create-scratch/SKILL.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -name: create-scratch -description: Create a new scratch file in .agents/scratches/ with a unique three-word ID, frontmatter, and formatted title -license: MIT -compatibility: opencode -metadata: - audience: agents - workflow: exploration ---- - -## What I do - -I help you create new scratch files in the `.agents/scratches/` directory. Each scratch file gets: - -- A unique three-word identifier (e.g., `calm-teal-cloud`) -- Frontmatter with the current date and formatted title -- Content you provide - -## How to use - -Run the script with a slug and content: - -```bash -npx tsx scripts/create-scratch.ts "note-name" "Scratch content here" -``` - -Or use heredoc for multi-line content: - -```bash -npx tsx scripts/create-scratch.ts "note-name" << HEREDOC -Multi-line -scratch content -goes here -HEREDOC -``` - -## File format - -Files are created as: `{three-word-id}-{slug}.md` - -Example: `calm-teal-cloud-note-name.md` - -The file includes frontmatter: - -```markdown ---- -date: 2026-01-13 -title: Note Name ---- - -Your content here -``` - -## When to use me - -Use this skill when you need to create a temporary note, exploration document, or scratch pad for ideas. The unique ID ensures no filename conflicts, and the frontmatter provides metadata for organization. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55ed7f27d..e3b1007da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,7 @@ jobs: build_app: name: Build App runs-on: ubuntu-latest + timeout-minutes: 60 steps: - name: Checkout uses: actions/checkout@v4 @@ -32,6 +33,7 @@ jobs: build_docker: name: Build Docker Image runs-on: ubuntu-latest + timeout-minutes: 60 steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index d74f30387..b5ac9017a 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -11,6 +11,7 @@ jobs: analyze: name: Analyze runs-on: ubuntu-latest + timeout-minutes: 60 permissions: actions: read contents: read diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 80d188964..00132070d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -8,6 +8,7 @@ on: jobs: deploy: runs-on: ubuntu-latest + timeout-minutes: 60 steps: - name: Checkout code diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml index 34d7a478f..d8589bf1b 100644 --- a/.github/workflows/issue-labeler.yml +++ b/.github/workflows/issue-labeler.yml @@ -7,6 +7,7 @@ on: jobs: label-when-assigned: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Label issue uses: actions/github-script@v6 diff --git a/.github/workflows/issue-opened.yml b/.github/workflows/issue-opened.yml index 92b559d11..fd4a60151 100644 --- a/.github/workflows/issue-opened.yml +++ b/.github/workflows/issue-opened.yml @@ -7,6 +7,7 @@ on: jobs: label_issues: runs-on: ubuntu-latest + timeout-minutes: 10 permissions: issues: write steps: diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index 15fe7cbfa..5c3eeaac2 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -13,6 +13,7 @@ jobs: contents: read pull-requests: write runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/labeler@v4 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 50137d2e1..f28fc4e1f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,6 +14,7 @@ jobs: build_and_publish_platform_containers: name: Build and publish platform containers runs-on: ${{ matrix.os }} + timeout-minutes: 60 strategy: fail-fast: false matrix: @@ -78,6 +79,7 @@ jobs: create_and_publish_manifest: name: Create and publish manifest runs-on: ubuntu-latest + timeout-minutes: 60 needs: build_and_publish_platform_containers steps: - name: Checkout diff --git a/.github/workflows/semantic-pull-requests.yml b/.github/workflows/semantic-pull-requests.yml index 0dab3392d..34e731436 100644 --- a/.github/workflows/semantic-pull-requests.yml +++ b/.github/workflows/semantic-pull-requests.yml @@ -15,6 +15,7 @@ jobs: validate-pr: name: Validate PR title runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: amannn/action-semantic-pull-request@v5 id: lint_pr_title diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index c9c12ce59..aed53da8c 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -7,6 +7,7 @@ on: jobs: stale: runs-on: ubuntu-latest + timeout-minutes: 10 permissions: issues: write pull-requests: write diff --git a/.github/workflows/translations-force-pull.yml b/.github/workflows/translations-force-pull.yml index 5d804df63..b0893a2aa 100644 --- a/.github/workflows/translations-force-pull.yml +++ b/.github/workflows/translations-force-pull.yml @@ -18,6 +18,7 @@ jobs: pull_translations: name: Force pull translations runs-on: ubuntu-latest + timeout-minutes: 10 environment: Translations permissions: contents: write diff --git a/.github/workflows/translations-pull.yml b/.github/workflows/translations-pull.yml index 0e3703438..ea65548f2 100644 --- a/.github/workflows/translations-pull.yml +++ b/.github/workflows/translations-pull.yml @@ -16,6 +16,7 @@ jobs: pull_translations: name: Pull translations runs-on: ubuntu-latest + timeout-minutes: 10 environment: Translations permissions: contents: write diff --git a/.github/workflows/translations-upload.yml b/.github/workflows/translations-upload.yml index 0e80def07..b81b6375f 100644 --- a/.github/workflows/translations-upload.yml +++ b/.github/workflows/translations-upload.yml @@ -14,6 +14,7 @@ jobs: extract_translations: name: Extract and upload translations runs-on: ubuntu-latest + timeout-minutes: 30 environment: Translations permissions: contents: write diff --git a/.npmrc b/.npmrc index 75baad7f0..cbc6b6537 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,3 @@ legacy-peer-deps = true prefer-dedupe = true -# min-release-age = 7 +min-release-age = 7 diff --git a/apps/docs/content/docs/developers/api/documents.mdx b/apps/docs/content/docs/developers/api/documents.mdx index 293182057..bd526e828 100644 --- a/apps/docs/content/docs/developers/api/documents.mdx +++ b/apps/docs/content/docs/developers/api/documents.mdx @@ -28,35 +28,62 @@ Each document contains one or more PDF files, a list of recipients, and the fiel A document object contains the following properties: -| Property | Type | Description | -| --------------- | -------------- | -------------------------------------------------------------- | -| `id` | string | Unique identifier (e.g., `envelope_abc123`) | -| `type` | string | `DOCUMENT` or `TEMPLATE` | -| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, or `CANCELLED` | -| `title` | string | Document title | -| `source` | string | How the document was created: `DOCUMENT`, `TEMPLATE`, `TEMPLATE_DIRECT_LINK` | -| `visibility` | string | Who can view: `EVERYONE`, `ADMIN`, `MANAGER_AND_ABOVE` | -| `externalId` | string \| null | Your custom identifier for the document | -| `createdAt` | string | ISO 8601 timestamp | -| `updatedAt` | string | ISO 8601 timestamp | -| `completedAt` | string \| null | Timestamp when all recipients completed signing | -| `deletedAt` | string \| null | Timestamp if soft-deleted | -| `recipients` | array | List of recipients and their signing status | -| `fields` | array | Signature and form fields on the document | -| `envelopeItems` | array | PDF files attached to the document | -| `documentMeta` | object | Email settings, redirect URL, signing options | +| Property | Type | Description | +| ------------------- | -------------- | -------------------------------------------------------------------------------------------- | +| `id` | string | Unique identifier (e.g., `envelope_abc123`) | +| `secondaryId` | string | Legacy identifier in prefixed form (`document_123` for documents, `template_123` for templates) | +| `internalVersion` | number | Internal envelope schema version | +| `type` | string | `DOCUMENT` or `TEMPLATE` | +| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, or `CANCELLED` | +| `title` | string | Document title | +| `source` | string | How the document was created: `DOCUMENT`, `TEMPLATE`, `TEMPLATE_DIRECT_LINK` | +| `visibility` | string | Who can view: `EVERYONE`, `ADMIN`, `MANAGER_AND_ABOVE` | +| `templateType` | string | Template visibility: `PUBLIC`, `PRIVATE`, or `ORGANISATION` (only meaningful for templates) | +| `externalId` | string \| null | Your custom identifier for the document | +| `userId` | number | ID of the user who owns the document | +| `teamId` | number | ID of the team the document belongs to | +| `folderId` | string \| null | ID of the folder containing the document | +| `templateId` | number \| null | Legacy ID of the template this document was created from | +| `authOptions` | object \| null | Access and action authentication requirements | +| `formValues` | object \| null | Pre-filled form values | +| `publicTitle` | string | Public title shown on profile and direct-link pages | +| `publicDescription` | string | Public description shown on profile and direct-link pages | +| `createdAt` | string | ISO 8601 timestamp | +| `updatedAt` | string | ISO 8601 timestamp | +| `completedAt` | string \| null | Timestamp when all recipients completed signing | +| `deletedAt` | string \| null | Timestamp if soft-deleted | +| `recipients` | array | List of recipients and their signing status | +| `fields` | array | Signature and form fields on the document | +| `envelopeItems` | array | PDF files attached to the document | +| `directLink` | object \| null | Direct-link signing configuration (`id`, `token`, `enabled`, `directTemplateRecipientId`) | +| `team` | object | Owning team (`id`, `url`) | +| `user` | object | Document owner (`id`, `name`, `email`) | +| `documentMeta` | object | Email settings, redirect URL, signing options | + +Documents created through the API have `source: "DOCUMENT"` — there is no separate `API` source value. To tag documents created by your integration, set `externalId` when creating them. ### Example Document Object ```json { "id": "envelope_abc123xyz", + "secondaryId": "document_123", + "internalVersion": 2, "type": "DOCUMENT", "status": "PENDING", "source": "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, @@ -74,22 +101,40 @@ A document object contains the following properties: "fields": [ { "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 + "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.", diff --git a/apps/docs/content/docs/developers/api/fields.mdx b/apps/docs/content/docs/developers/api/fields.mdx index ce31d1588..0cec72339 100644 --- a/apps/docs/content/docs/developers/api/fields.mdx +++ b/apps/docs/content/docs/developers/api/fields.mdx @@ -6,6 +6,8 @@ description: Add signature and form fields to documents via API. import { Callout } from 'fumadocs-ui/components/callout'; import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; + + This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference, see the [OpenAPI Reference](https://openapi.documenso.com). @@ -62,7 +64,7 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; | Type | Description | Auto-filled | | ---------------- | ----------------------------------------- | ----------- | | `SIGNATURE` | Drawn, typed, or uploaded signature | No | -| `FREE_SIGNATURE` | Unrestricted signature without validation | No | +| `FREE_SIGNATURE` | Legacy free-form signature. Accepted by the v2 create schema but rejected by the v1 API and unsupported in the signing UI — avoid in new integrations | No | | `INITIALS` | Recipient's initials | No | | `NAME` | Recipient's full name | Yes | | `EMAIL` | Recipient's email address | Yes | @@ -140,6 +142,8 @@ POST /envelope/field/create-many | `envelopeId` | string | Yes | The envelope ID | | `data` | array | Yes | Array of field configurations | +Each entry in `data` requires a `type`, a `recipientId`, and a position — either explicit coordinates (`page`, `positionX`, `positionY`, `width`, `height`) or a [text placeholder](#placeholder-based-field-positioning) (`placeholder` with optional `width`, `height`, and `matchAll`). Optional per-entry properties: `envelopeItemId` (which PDF in the envelope to place the field on; defaults to the first item) and `fieldMeta`. + ### Code Examples @@ -335,6 +339,8 @@ POST /envelope/field/update-many | `envelopeId` | string | Yes | The envelope ID | | `data` | array | Yes | Array of field update objects | +Each entry in `data` requires the field `id` and `type`. Position properties (`page`, `positionX`, `positionY`, `width`, `height`), `envelopeItemId`, and `fieldMeta` are optional — only supplied values are updated. Placeholder positioning is not supported when updating; use coordinates. + ### Code Examples @@ -551,6 +557,33 @@ This approach is useful when generating PDFs programmatically or using templates See the [PDF Placeholders](/docs/users/documents/advanced/pdf-placeholders) guide for the full placeholder format reference, including supported field types, recipient identifiers, and field options. +### Placeholder Positioning via the API + +`POST /envelope/field/create-many` accepts a placeholder position in place of coordinates. Instead of `page`, `positionX`, `positionY`, `width`, and `height`, pass: + +| Field | Type | Required | Description | +| ------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------ | +| `placeholder` | string | Yes | Text to search for in the PDF (e.g. `{{name}}`). The field is placed at the bounding box of the first match. | +| `width` | number | No | Override the field width. Defaults to the width of the matched text. | +| `height` | number | No | Override the field height. Defaults to the height of the matched text. | +| `matchAll` | boolean | No | Create a field at every occurrence of the placeholder instead of only the first. | + +```json +{ + "envelopeId": "envelope_abcdefhiklmnorst", + "data": [ + { + "type": "SIGNATURE", + "recipientId": 456, + "placeholder": "{{signature}}", + "matchAll": true + } + ] +} +``` + +`POST /envelope/field/update-many` does not accept placeholders — field updates are coordinate-only. + --- ## Field Meta Options diff --git a/apps/docs/content/docs/developers/getting-started/first-api-call.mdx b/apps/docs/content/docs/developers/getting-started/first-api-call.mdx index d1cc4f46a..3f9083959 100644 --- a/apps/docs/content/docs/developers/getting-started/first-api-call.mdx +++ b/apps/docs/content/docs/developers/getting-started/first-api-call.mdx @@ -441,7 +441,7 @@ The API returns standard HTTP status codes and JSON error responses: | `400` | Bad request - check your request payload | | `401` | Unauthorized - invalid or missing API token | | `404` | Not found - resource doesn't exist | -| `429` | Rate limited - wait for the duration in the `Retry-After` header | +| `429` | Rate limit or plan quota. If `Retry-After` is present, retry the request. | | `500` | Server error - retry or contact support | ### Error Response Format @@ -485,7 +485,9 @@ The API returns standard HTTP status codes and JSON error responses: ### Handling Rate Limits -The API allows 1000 requests per minute per IP address. Your organisation may have its own lower rate limits. Every response includes `X-RateLimit-Remaining` and `X-RateLimit-Reset` (an epoch timestamp in seconds). When you receive a `429` response, read the `Retry-After` header and wait for that many seconds before retrying. See [Error Handling Patterns](/docs/developers/examples/common-workflows#error-handling-patterns) for a more complete retry strategy. +The API has a limit of 1000 requests per minute for each IP address, and your organisation can have a lower limit. Each response includes `X-RateLimit-Remaining` and `X-RateLimit-Reset` (an epoch timestamp in seconds). A windowed rate-limit `429` response includes `Retry-After`. Wait for that number of seconds before you send the request again. A quota `429` response does not include `Retry-After` because a wait cannot correct the quota error. If `Retry-After` is not present, do not send the request again automatically. + +Refer to [Error Handling Patterns](/docs/developers/examples/common-workflows#error-handling-patterns) for more retry information. ```javascript async function fetchWithRetry(url, options, maxRetries = 3) { @@ -493,8 +495,14 @@ async function fetchWithRetry(url, options, maxRetries = 3) { const response = await fetch(url, options); if (response.status === 429) { - const retryAfterSeconds = Number.parseInt(response.headers.get('Retry-After') ?? '1', 10); - console.log(`Rate limited, waiting ${retryAfterSeconds} seconds...`); + const retryAfter = response.headers.get('Retry-After'); + + if (!retryAfter) { + return response; + } + + const retryAfterSeconds = Number.parseInt(retryAfter, 10); + console.log(`Rate limit. Wait ${retryAfterSeconds} seconds...`); await new Promise((resolve) => setTimeout(resolve, retryAfterSeconds * 1000)); continue; } diff --git a/apps/docs/content/docs/developers/webhooks/index.mdx b/apps/docs/content/docs/developers/webhooks/index.mdx index 93ed7f0d5..8c27eccbd 100644 --- a/apps/docs/content/docs/developers/webhooks/index.mdx +++ b/apps/docs/content/docs/developers/webhooks/index.mdx @@ -9,7 +9,7 @@ description: Receive real-time notifications for document and template events. 2. When an event occurs, Documenso sends an HTTP POST to your URL 3. Your application processes the event and responds with 200 OK -Documenso supports webhook events for the full document lifecycle (created, sent, opened, signed, completed, rejected, cancelled) as well as template events (created, updated, deleted, used). +Documenso supports webhook events for the full document lifecycle (created, sent, opened, signed, completed, rejected, cancelled), recipient-level events (recipient completed, reminder sent, recipient expired), and template events (created, updated, deleted, used). --- diff --git a/apps/docs/content/docs/developers/webhooks/setup.mdx b/apps/docs/content/docs/developers/webhooks/setup.mdx index a11cc72f7..88fda57ea 100644 --- a/apps/docs/content/docs/developers/webhooks/setup.mdx +++ b/apps/docs/content/docs/developers/webhooks/setup.mdx @@ -304,6 +304,7 @@ Each webhook call shows the following details: - Timestamp - Response code - Request and response bodies +- Response headers Click any call to see full details including headers and response data. diff --git a/apps/docs/content/docs/self-hosting/deployment/docker-compose.mdx b/apps/docs/content/docs/self-hosting/deployment/docker-compose.mdx index 84e228115..a5ac58807 100644 --- a/apps/docs/content/docs/self-hosting/deployment/docker-compose.mdx +++ b/apps/docs/content/docs/self-hosting/deployment/docker-compose.mdx @@ -81,7 +81,7 @@ services: - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?err} - POSTGRES_DB=${POSTGRES_DB:?err} healthcheck: - test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}'] + test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}'] interval: 10s timeout: 5s retries: 5 diff --git a/apps/docs/package.json b/apps/docs/package.json index 9f345d14c..9539148d0 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -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", diff --git a/apps/openpage-api/package.json b/apps/openpage-api/package.json index bcc93e039..1b7c14350 100644 --- a/apps/openpage-api/package.json +++ b/apps/openpage-api/package.json @@ -12,11 +12,11 @@ "dependencies": { "@documenso/prisma": "*", "luxon": "^3.7.2", - "next": "16.2.6" + "next": "16.3.0" }, "devDependencies": { "@types/node": "^20", - "@types/react": "18.3.27", + "@types/react": "^19.2.17", "typescript": "5.6.2" } } diff --git a/apps/remix/app/components/dialogs/document-preferences-reset-dialog.tsx b/apps/remix/app/components/dialogs/document-preferences-reset-dialog.tsx index 9adebb36e..ed7184ef7 100644 --- a/apps/remix/app/components/dialogs/document-preferences-reset-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-preferences-reset-dialog.tsx @@ -18,7 +18,6 @@ export type DocumentPreferencesResetDialogProps = { onReset: () => Promise; showAiFeatures?: boolean; showDocumentVisibility?: boolean; - showIncludeSenderDetails?: boolean; }; export const DocumentPreferencesResetDialog = ({ @@ -26,7 +25,6 @@ export const DocumentPreferencesResetDialog = ({ onReset, showAiFeatures = false, showDocumentVisibility = false, - showIncludeSenderDetails = false, }: DocumentPreferencesResetDialogProps) => { const [open, setOpen] = useState(false); const [isResetting, setIsResetting] = useState(false); @@ -92,29 +90,12 @@ export const DocumentPreferencesResetDialog = ({
  • Default signature settings
  • - {showIncludeSenderDetails && ( -
  • - Send on behalf of team -
  • - )} -
  • - Include the signing certificate in the document -
  • -
  • - Include the audit logs in the document -
  • Default recipients
  • Delegate document ownership
  • -
  • - Default envelope expiration -
  • -
  • - Default signing reminders -
  • {showAiFeatures && (
  • AI features diff --git a/apps/remix/app/components/dialogs/envelopes-bulk-download-dialog.tsx b/apps/remix/app/components/dialogs/envelopes-bulk-download-dialog.tsx new file mode 100644 index 000000000..940055854 --- /dev/null +++ b/apps/remix/app/components/dialogs/envelopes-bulk-download-dialog.tsx @@ -0,0 +1,377 @@ +import { + createZipWriter, + sanitizeZipPathSegment, + type ZipFileEntry, +} from '@documenso/lib/client-only/create-zip-writer'; +import { downloadFile } from '@documenso/lib/client-only/download-file'; +import { fetchPDF } from '@documenso/lib/client-only/download-pdf'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; +import { RadioGroupSegmented, RadioGroupSegmentedItem } from '@documenso/ui/primitives/radio-group'; +import { useToast } from '@documenso/ui/primitives/use-toast'; +import { plural } from '@lingui/core/macro'; +import { Plural, Trans, useLingui } from '@lingui/react/macro'; +import { DocumentStatus } from '@prisma/client'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useEffect, useRef, useState } from 'react'; +import { match } from 'ts-pattern'; + +/** + * The maximum number of documents that can be downloaded in a single bulk + * download. Each document requires fetching its full PDFs into the browser, + * so this bounds both request volume and blob storage usage. Matches the + * spirit of the server-side 100 cap on bulk move/delete/cancel. + */ +export const MAX_BULK_DOWNLOAD_ENVELOPES = 50; + +type BulkDownloadVersion = 'signed' | 'original' | 'pending'; + +export type EnvelopeBulkDownloadItem = { + id: string; + title: string; + status: DocumentStatus; + + /** + * Whether the envelope is a legacy (v1) envelope. Legacy envelopes use a + * different field-rendering pipeline that the partial PDF helper does not + * implement, so the Partial option is hidden for them. + */ + isLegacy: boolean; +}; + +const getDefaultVersion = (envelope: EnvelopeBulkDownloadItem): BulkDownloadVersion => + envelope.status === DocumentStatus.COMPLETED ? 'signed' : 'original'; + +export type EnvelopesBulkDownloadDialogProps = { + envelopes: EnvelopeBulkDownloadItem[]; + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: (successfulEnvelopeIds: string[]) => void; +} & Omit; + +export const EnvelopesBulkDownloadDialog = ({ + envelopes, + open, + onOpenChange, + onSuccess, + ...props +}: EnvelopesBulkDownloadDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [versionMap, setVersionMap] = useState>({}); + const [progress, setProgress] = useState(0); + const [isDownloading, setIsDownloading] = useState(false); + + const abortRef = useRef(false); + + const trpcUtils = trpc.useUtils(); + + const isOverDownloadLimit = envelopes.length > MAX_BULK_DOWNLOAD_ENVELOPES; + + useEffect(() => { + if (!open) { + return; + } + + setVersionMap(Object.fromEntries(envelopes.map((envelope) => [envelope.id, getDefaultVersion(envelope)]))); + setProgress(0); + }, [open]); + + const getDownloadVersion = (envelope: EnvelopeBulkDownloadItem): BulkDownloadVersion => + versionMap[envelope.id] ?? getDefaultVersion(envelope); + + /** + * The version options selectable for an envelope, mirroring the gating used + * by the single envelope download dialog: + * - COMPLETED: signed or original. + * - PENDING (non-legacy): partial or original. Legacy envelopes use a + * field-rendering pipeline the partial PDF helper does not implement. + * - Anything else: original only, so no choice is shown. + */ + const getVersionOptions = ( + envelope: EnvelopeBulkDownloadItem, + ): { value: BulkDownloadVersion; label: string }[] | null => { + if (envelope.status === DocumentStatus.COMPLETED) { + return [ + { value: 'signed', label: t({ message: 'Signed', context: 'Signed document (adjective)' }) }, + { value: 'original', label: t({ message: 'Original', context: 'Original document (adjective)' }) }, + ]; + } + + if (envelope.status === DocumentStatus.PENDING && !envelope.isLegacy) { + return [ + { value: 'pending', label: t({ message: 'Partial', context: 'Partially signed document (adjective)' }) }, + { value: 'original', label: t({ message: 'Original', context: 'Original document (adjective)' }) }, + ]; + } + + return null; + }; + + const getStatusLabel = (status: DocumentStatus) => + match(status) + .with(DocumentStatus.COMPLETED, () => t`Completed`) + .with(DocumentStatus.PENDING, () => t`Pending`) + .with(DocumentStatus.DRAFT, () => t`Draft`) + .with(DocumentStatus.REJECTED, () => t`Rejected`) + .with(DocumentStatus.CANCELLED, () => t`Cancelled`) + .exhaustive(); + + const onDownload = async () => { + if (envelopes.length === 0 || isOverDownloadLimit || isDownloading) { + return; + } + + abortRef.current = false; + setIsDownloading(true); + setProgress(0); + + const zipWriter = createZipWriter(); + + const successfulEnvelopeIds: string[] = []; + let failedDownloads = 0; + + try { + for (const envelope of envelopes) { + if (abortRef.current) { + break; + } + + try { + const downloadVersion = getDownloadVersion(envelope); + + const { data: envelopeItems } = await trpcUtils.envelope.item.getManyByToken.fetch({ + envelopeId: envelope.id, + access: { + type: 'user', + }, + }); + + // Each envelope's items are grouped in their own folder. The id + // prefix guarantees uniqueness, the truncated title keeps it + // readable without risking overly long extraction paths. + const folderName = sanitizeZipPathSegment(`${envelope.id}_${envelope.title}`.slice(0, 96)); + + // Buffer this envelope's files before writing so a failed envelope + // is either fully in the zip or not at all. Files from previous + // envelopes have already been written to the zip stream and freed. + const envelopeFiles: ZipFileEntry[] = []; + + for (const envelopeItem of envelopeItems) { + const { filename, blob } = await fetchPDF({ + envelopeItem, + token: undefined, + fileName: envelopeItem.title, + version: downloadVersion, + }); + + envelopeFiles.push({ + filename: `${folderName}/${sanitizeZipPathSegment(filename)}`, + data: blob, + }); + } + + for (const file of envelopeFiles) { + await zipWriter.addFile(file); + } + + successfulEnvelopeIds.push(envelope.id); + } catch (error) { + console.error(error); + failedDownloads++; + } + + setProgress((p) => p + 1); + } + + // The user intentionally stopped the download, discard anything fetched + // so far without toasting an error. + if (abortRef.current) { + zipWriter.abort(); + return; + } + + if (successfulEnvelopeIds.length === 0) { + zipWriter.abort(); + + toast({ + title: t`Error`, + description: t`An error occurred while downloading the documents.`, + variant: 'destructive', + }); + return; + } + + try { + downloadFile({ + filename: `documenso-documents-${new Date().toISOString().slice(0, 10)}.zip`, + data: zipWriter.finalize(), + }); + } catch (error) { + console.error(error); + + zipWriter.abort(); + + toast({ + title: t`Error`, + description: t`An error occurred while downloading the documents.`, + variant: 'destructive', + }); + + return; + } + + if (failedDownloads > 0) { + toast({ + title: t`Documents partially downloaded`, + description: t`${plural(successfulEnvelopeIds.length, { + one: '# document downloaded.', + other: '# documents downloaded.', + })} ${plural(failedDownloads, { + one: '# document could not be downloaded.', + other: '# documents could not be downloaded.', + })}`, + variant: 'destructive', + }); + onSuccess?.(successfulEnvelopeIds); + return; + } + + toast({ + title: t`Documents downloaded`, + description: plural(successfulEnvelopeIds.length, { + one: '# document has been downloaded.', + other: '# documents have been downloaded.', + }), + }); + + onSuccess?.(successfulEnvelopeIds); + onOpenChange(false); + } finally { + setIsDownloading(false); + } + }; + + return ( + { + if (!isDownloading) { + onOpenChange(value); + } + }} + > + + + + Download Documents + + + + + + + + {isOverDownloadLimit && ( + + + + You can download up to {MAX_BULK_DOWNLOAD_ENVELOPES} documents at a time. Deselect some documents to + continue. + + + + )} + +
    +
    +
    + {envelopes.map((envelope) => { + const versionOptions = getVersionOptions(envelope); + + return ( +
    +
    +

    + {envelope.title} +

    +

    {getStatusLabel(envelope.status)}

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

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

    + )} + + + + + + +
    +
    +
    + ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-create-dialog.tsx b/apps/remix/app/components/dialogs/organisation-create-dialog.tsx index c76bc00a2..9bbec2636 100644 --- a/apps/remix/app/components/dialogs/organisation-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/organisation-create-dialog.tsx @@ -1,7 +1,7 @@ import type { InternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { useSession } from '@documenso/lib/client-only/providers/session'; -import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL, IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { AppError } from '@documenso/lib/errors/app-error'; import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription'; import { parseMessageDescriptorMacro } from '@documenso/lib/utils/i18n'; @@ -380,7 +380,7 @@ const BillingPlanForm = ({ value, onChange, plans, canCreateFreeOrganisation }: ))} diff --git a/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx b/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx index 7c08cf7d3..147cf3b40 100644 --- a/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx @@ -17,28 +17,25 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { Prisma } from '@prisma/client'; +import type { Team, TeamEmail, TeamEmailVerification } from '@prisma/client'; import { useState } from 'react'; import { useRevalidator } from 'react-router'; export type TeamEmailDeleteDialogProps = { trigger?: React.ReactNode; teamName: string; - team: Prisma.TeamGetPayload<{ - include: { - teamEmail: true; - emailVerification: { - select: { - expiresAt: true; - name: true; - email: true; - }; - }; - }; - }>; + team: Pick; + teamEmail: Pick | null; + emailVerification: Pick | null; }; -export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDeleteDialogProps) => { +export const TeamEmailDeleteDialog = ({ + trigger, + teamName, + team, + teamEmail, + emailVerification, +}: TeamEmailDeleteDialogProps) => { const [open, setOpen] = useState(false); const { _ } = useLingui(); @@ -83,11 +80,11 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele }); const onRemove = async () => { - if (team.teamEmail) { + if (teamEmail) { await deleteTeamEmail({ teamId: team.id }); } - if (team.emailVerification) { + if (emailVerification) { await deleteTeamEmailVerification({ teamId: team.id }); } @@ -121,13 +118,13 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele - {team.teamEmail?.name || team.emailVerification?.name} + {teamEmail?.name || emailVerification?.name} } - secondaryText={{team.teamEmail?.email || team.emailVerification?.email}} + secondaryText={{teamEmail?.email || emailVerification?.email}} /> diff --git a/apps/remix/app/components/dialogs/team-email-update-dialog.tsx b/apps/remix/app/components/dialogs/team-email-update-dialog.tsx index 3fbddc3c2..449d5ec36 100644 --- a/apps/remix/app/components/dialogs/team-email-update-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-email-update-dialog.tsx @@ -23,7 +23,8 @@ import { useRevalidator } from 'react-router'; import type { z } from 'zod'; export type TeamEmailUpdateDialogProps = { - teamEmail: TeamEmail; + teamId: number; + teamEmail: Pick; trigger?: React.ReactNode; } & Omit; @@ -33,7 +34,7 @@ const ZUpdateTeamEmailFormSchema = ZUpdateTeamEmailMutationSchema.pick({ type TUpdateTeamEmailFormSchema = z.infer; -export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmailUpdateDialogProps) => { +export const TeamEmailUpdateDialog = ({ teamId, teamEmail, trigger, ...props }: TeamEmailUpdateDialogProps) => { const [open, setOpen] = useState(false); const { t } = useLingui(); @@ -53,7 +54,7 @@ export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmai const onFormSubmit = async ({ name }: TUpdateTeamEmailFormSchema) => { try { await updateTeamEmail({ - teamId: teamEmail.teamId, + teamId, data: { name, }, diff --git a/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx b/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx index 669f5274c..c8726ae22 100644 --- a/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx +++ b/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx @@ -1,6 +1,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', }); } diff --git a/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx b/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx index b144d1c46..15902715d 100644 --- a/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx +++ b/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx @@ -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 { diff --git a/apps/remix/app/components/forms/branding-preferences-form.tsx b/apps/remix/app/components/forms/branding-preferences-form.tsx index e556ff6cf..1cb5be35d 100644 --- a/apps/remix/app/components/forms/branding-preferences-form.tsx +++ b/apps/remix/app/components/forms/branding-preferences-form.tsx @@ -29,6 +29,7 @@ import { useOptionalCurrentTeam } from '~/providers/team'; import { useCspNonce } from '~/utils/nonce'; import { FormStickySaveBar } from './form-sticky-save-bar'; +import { InheritableField } from './inheritable-field'; const ZBrandingPreferencesFormSchema = z.object({ brandingEnabled: z.boolean().nullable(), @@ -210,11 +211,13 @@ export function BrandingPreferencesForm({ control={form.control} name="brandingEnabled" render={({ field }) => ( - - - Enable Custom Branding - - + Enable Custom Branding} + testId="branding-enabled" + > @@ -372,7 +379,7 @@ export function BrandingPreferencesForm({ )} - + )} /> @@ -380,11 +387,13 @@ export function BrandingPreferencesForm({ control={form.control} name="brandingCompanyDetails" render={({ field }) => ( - - - Brand Details - - + Brand Details} + testId="branding-company-details" + >