mirror of
https://github.com/documenso/documenso.git
synced 2026-08-15 19:11:49 +10:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ace46fefd | ||
|
|
478229aa90 | ||
|
|
d3eb0c7999 | ||
|
|
918e42b992 | ||
|
|
f21eddef19 | ||
|
|
6ec67d1c4d | ||
|
|
a457e1ef7d | ||
|
|
e4897fa686 | ||
|
|
c02dfaba1a | ||
|
|
54befb5962 | ||
|
|
26f0c4c5b7 | ||
|
|
7f85388eb7 | ||
|
|
3cf2963cd0 | ||
|
|
cc5ef3df16 | ||
|
|
4b72e7d546 | ||
|
|
ba0dead96f |
@@ -0,0 +1,146 @@
|
|||||||
|
---
|
||||||
|
date: 2026-05-28
|
||||||
|
title: Rejected Expired Recipient Filters
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Customers need to find (a) envelopes/documents in the `REJECTED` state and (b) envelopes
|
||||||
|
with at least one recipient whose signing link has **expired**. Today the UI only exposes
|
||||||
|
`INBOX / PENDING / COMPLETED / DRAFT / ALL` tabs, and the public API has no way to filter by
|
||||||
|
expired recipient links — forcing a fetch-all-`PENDING`-then-inspect-each-recipient workaround.
|
||||||
|
|
||||||
|
Two key facts from exploration shaped this plan:
|
||||||
|
|
||||||
|
- **`REJECTED` is already fully wired in the backend** — the where-clause (`find-documents.ts`),
|
||||||
|
stats counts (`get-stats.ts`), tRPC response schema, `ExtendedDocumentStatus` enum, and the
|
||||||
|
`FRIENDLY_STATUS_MAP` display all handle it. It is simply absent from the UI tab array.
|
||||||
|
- **Renewing expired links already works.** `resendDocument` refreshes `expiresAt` and clears
|
||||||
|
`expirationNotifiedAt` for unsigned, non-CC recipients (`resend-document.ts:98-121`), exposed
|
||||||
|
publicly via `POST /api/v2/document/redistribute` and `/api/v2/envelope/redistribute` and via the
|
||||||
|
resend/redistribute UI dialogs. No new renew mechanism is needed — only documentation/wording.
|
||||||
|
|
||||||
|
Expiration is a per-recipient condition (not an envelope status). The approved design models it
|
||||||
|
in the UI as an `EXPIRED` **pseudo-status tab** (reusing the existing tab machinery, mirroring how
|
||||||
|
`REJECTED` works) and in the public API as an orthogonal boolean `hasExpiredRecipients`. Both share
|
||||||
|
one EXISTS predicate.
|
||||||
|
|
||||||
|
Definition of "expired recipient" (matches `isRecipientExpired`, `packages/lib/utils/recipients.ts:118`):
|
||||||
|
a `Recipient` with `expiresAt IS NOT NULL AND expiresAt <= now() AND signingStatus = NOT_SIGNED AND role != CC`.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
### A. Shared EXISTS predicate (reused 4x, justified)
|
||||||
|
Add a local `hasExpiredRecipient(eb)` helper — modeled on the existing per-file `recipientExists` /
|
||||||
|
`senderEmailIs` helpers — to `find-documents.ts`, `get-stats.ts`, and `find-envelopes.ts`. It is the
|
||||||
|
single source of truth for the expired condition above (using `new Date()` for `now`, matching the
|
||||||
|
`period` filter's `.toJSDate()` style).
|
||||||
|
|
||||||
|
### B. REJECTED tab (UI only — backend already done)
|
||||||
|
- `apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx`: add
|
||||||
|
`ExtendedDocumentStatus.REJECTED` to the tab array (lines 149-155). Count badge, highlight, and
|
||||||
|
`?status=REJECTED` filtering already work via existing machinery.
|
||||||
|
|
||||||
|
### C. EXPIRED pseudo-status (UI + internal stats)
|
||||||
|
1. `packages/prisma/types/extended-document-status.ts`: add `EXPIRED: 'EXPIRED'`. Internal-only —
|
||||||
|
the public `DocumentStatus` enum is unaffected. This intentionally surfaces TS errors at the three
|
||||||
|
exhaustive/`Record<ExtendedDocumentStatus>` sites below, forcing them to be handled.
|
||||||
|
2. `packages/lib/server-only/document/find-documents.ts`:
|
||||||
|
- Add `.with(ExtendedDocumentStatus.EXPIRED, ...)` to **both** `applyPersonalFilters` and
|
||||||
|
`applyTeamFilters`, mirroring the `COMPLETED` branch's access control (deleted + visibility +
|
||||||
|
owner/recipient access) with `hasExpiredRecipient(eb)` AND-ed in. Do **not** constrain
|
||||||
|
`Envelope.status` — the EXISTS already restricts to unsigned recipients.
|
||||||
|
3. `packages/lib/server-only/document/get-stats.ts`:
|
||||||
|
- Add an `expiredQuery` mirroring `pendingQuery`'s access control + `hasExpiredRecipient(eb)`.
|
||||||
|
- Add it to the `Promise.all`, add `[ExtendedDocumentStatus.EXPIRED]: expired` to the `stats`
|
||||||
|
record. **Do not** add `expired` to the `all` sum (it overlaps `PENDING`).
|
||||||
|
4. `packages/trpc/server/document-router/find-documents-internal.types.ts`: add
|
||||||
|
`[ExtendedDocumentStatus.EXPIRED]: z.number()` to the `stats` response object. (`status` already
|
||||||
|
accepts the extended enum via `z.nativeEnum(ExtendedDocumentStatus)`.)
|
||||||
|
5. `apps/remix/app/components/general/document/document-status.tsx`: add an `EXPIRED` entry to
|
||||||
|
`FRIENDLY_STATUS_MAP` — `label: msg` Expired, an icon (e.g. lucide `TimerOff`, matching the
|
||||||
|
`/sign/$token/expired` page), and a distinct color (e.g. `text-orange-500`) to differentiate from
|
||||||
|
`REJECTED` (red).
|
||||||
|
6. `documents._index.tsx`: add `[ExtendedDocumentStatus.EXPIRED]: 0` to the `stats` `useState`
|
||||||
|
initializer and `ExtendedDocumentStatus.EXPIRED` to the tab array. Final order:
|
||||||
|
`INBOX, PENDING, COMPLETED, DRAFT, REJECTED, EXPIRED, ALL`.
|
||||||
|
7. (Optional, recommended) `apps/remix/app/components/tables/documents-table-empty-state.tsx`: add
|
||||||
|
tailored `EXPIRED` and `REJECTED` empty-state copy (currently both fall through to `.otherwise()`).
|
||||||
|
|
||||||
|
### D. Public API boolean `hasExpiredRecipients` (document + envelope, v2)
|
||||||
|
1. `packages/lib/server-only/document/find-documents.ts`: add `hasExpiredRecipients?: boolean` to
|
||||||
|
`FindDocumentsOptions`; when true, apply `.where((eb) => hasExpiredRecipient(eb))` inside
|
||||||
|
`buildBaseQuery` (orthogonal/additive to any `status`).
|
||||||
|
2. `packages/trpc/server/document-router/find-documents.types.ts`: add a query-safe boolean
|
||||||
|
`hasExpiredRecipients` to `ZFindDocumentsRequestSchema` with a `.describe(...)`. Mirror the
|
||||||
|
existing boolean-query-param handling in `find-document-audit-logs.types.ts`
|
||||||
|
(`filterForRecentActivity`) — avoid raw `z.coerce.boolean()` (the "false" -> true footgun); use a
|
||||||
|
string transform if needed. Pass it through in `find-documents.ts` (public handler).
|
||||||
|
3. `packages/lib/server-only/envelope/find-envelopes.ts`: add `hasExpiredRecipients?: boolean` to
|
||||||
|
`FindEnvelopesOptions` + the `hasExpiredRecipient(eb)` helper + the additive `.where`.
|
||||||
|
4. `packages/trpc/server/envelope-router/find-envelopes.types.ts`: add the same param to
|
||||||
|
`ZFindEnvelopesRequestSchema`; pass it through in the envelope-router find handler.
|
||||||
|
The param auto-appears in the generated `/api/v2/openapi.json`.
|
||||||
|
|
||||||
|
Note: REST v1 `GET /api/v1/documents` is deprecated and lacks status filtering — left unchanged.
|
||||||
|
`REJECTED` is already a valid public `status` value (`DocumentStatus.REJECTED`), so no API change is
|
||||||
|
needed for rejected filtering.
|
||||||
|
|
||||||
|
### E. Renew expired links — documentation only
|
||||||
|
No functional change. Document that resending renews expired links:
|
||||||
|
- Update the `.description` in `packages/trpc/server/document-router/redistribute-document.types.ts`
|
||||||
|
and `packages/trpc/server/envelope-router/redistribute-envelope.types.ts` to state that
|
||||||
|
redistributing refreshes the signing-link expiration for unsigned recipients.
|
||||||
|
- Optionally adjust resend/redistribute dialog copy
|
||||||
|
(`apps/remix/app/components/dialogs/document-resend-dialog.tsx`,
|
||||||
|
`envelope-redistribute-dialog.tsx`) to mention it renews expired links.
|
||||||
|
|
||||||
|
## Files To Modify (summary)
|
||||||
|
|
||||||
|
| Area | File |
|
||||||
|
|------|------|
|
||||||
|
| Enum | `packages/prisma/types/extended-document-status.ts` |
|
||||||
|
| Where-clause + API option | `packages/lib/server-only/document/find-documents.ts` |
|
||||||
|
| Stats counts | `packages/lib/server-only/document/get-stats.ts` |
|
||||||
|
| Envelope find (API) | `packages/lib/server-only/envelope/find-envelopes.ts` |
|
||||||
|
| Internal tRPC stats schema | `packages/trpc/server/document-router/find-documents-internal.types.ts` |
|
||||||
|
| Public doc API schema + handler | `packages/trpc/server/document-router/find-documents.types.ts`, `find-documents.ts` |
|
||||||
|
| Public envelope API schema + handler | `packages/trpc/server/envelope-router/find-envelopes.types.ts`, `find-envelopes.ts` |
|
||||||
|
| Status display | `apps/remix/app/components/general/document/document-status.tsx` |
|
||||||
|
| Tabs + stats init | `apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx` |
|
||||||
|
| Empty state (optional) | `apps/remix/app/components/tables/documents-table-empty-state.tsx` |
|
||||||
|
| Renew docs | `redistribute-document.types.ts`, `redistribute-envelope.types.ts` (+ resend dialogs, optional) |
|
||||||
|
|
||||||
|
## Reused Utilities / Patterns
|
||||||
|
- `recipientExists` / `senderEmailIs` (per-file Kysely EXISTS helpers) — the template for the new
|
||||||
|
`hasExpiredRecipient` helper.
|
||||||
|
- `REJECTED` branches in `find-documents.ts` (lines 279, 416) and `rejectedQuery` in `get-stats.ts`
|
||||||
|
(line 227) — the template for the `EXPIRED` branches / `expiredQuery`.
|
||||||
|
- `isRecipientExpired` (`packages/lib/utils/recipients.ts:118`) — defines the `expiresAt <= now`
|
||||||
|
semantics to match.
|
||||||
|
- Existing tab machinery in `documents._index.tsx` (`getTabHref`, count badge, personal-org `.filter`)
|
||||||
|
— works unchanged for the new tabs.
|
||||||
|
- `resendDocument` / `trpc.document.redistribute` / `trpc.envelope.redistribute` — existing renew path.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
1. **Typecheck** (the enum change forces all exhaustive/Record sites): `npm run typecheck -w @documenso/remix`.
|
||||||
|
2. **Seed + UI** (dev server already running): seed a team via `seedTeam`, send a document, then:
|
||||||
|
- Reject one as a recipient -> it appears under the new **Rejected** tab with a count.
|
||||||
|
- Force expiry (set a recipient `expiresAt` in the past, e.g. via Prisma Studio or a short
|
||||||
|
`envelopeExpirationPeriod`) -> the doc appears under the new **Expired** tab with a count, and the
|
||||||
|
count excludes signed/CC recipients.
|
||||||
|
3. **Public API**: `GET /api/v2/document?hasExpiredRecipients=true` and
|
||||||
|
`GET /api/v2/envelope?hasExpiredRecipients=true` (Bearer API token) return only envelopes with >=1
|
||||||
|
expired unsigned recipient; confirm `GET /api/v2/document?status=REJECTED` works. Verify the param
|
||||||
|
appears in `/api/v2/openapi.json`.
|
||||||
|
4. **Renew**: on an expired doc, run resend/redistribute (UI dialog or
|
||||||
|
`POST /api/v2/document/redistribute`) -> recipient `expiresAt` is refreshed, the doc leaves the
|
||||||
|
Expired tab, and the signing link no longer redirects to `/sign/$token/expired`.
|
||||||
|
5. **E2E** (optional): extend `packages/app-tests/e2e/envelopes/envelope-expiration-send.spec.ts`
|
||||||
|
with an Expired-tab assertion.
|
||||||
|
6. Do **not** modify/commit `packages/lib/translations/*.po`; run `npm run translate` only if needed
|
||||||
|
for new `msg`/`Trans` strings, and keep generated `.po` files out of the branch.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
- Exact icon/color for the `EXPIRED` tab (proposed: `TimerOff`, `text-orange-500`).
|
||||||
|
- Whether to add the optional tailored empty-state copy now or defer.
|
||||||
+1
-1
@@ -42,8 +42,8 @@ Documenso is an open-source document signing platform built as a **monorepo** us
|
|||||||
| Package | Description | Port |
|
| Package | Description | Port |
|
||||||
| -------------------------- | -------------------------------------------------------- | ---- |
|
| -------------------------- | -------------------------------------------------------- | ---- |
|
||||||
| `@documenso/remix` | Main application - React Router (Remix) with Hono server | 3000 |
|
| `@documenso/remix` | Main application - React Router (Remix) with Hono server | 3000 |
|
||||||
| `@documenso/documentation` | Documentation site (Next.js + Nextra) | 3002 |
|
|
||||||
| `@documenso/openpage-api` | Public analytics API | 3003 |
|
| `@documenso/openpage-api` | Public analytics API | 3003 |
|
||||||
|
| `@documenso/docs` | Documentation site | 3004 |
|
||||||
|
|
||||||
### Core Packages (`packages/`)
|
### Core Packages (`packages/`)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ description: Create, manage, and send documents for signing via the API.
|
|||||||
import { Callout } from 'fumadocs-ui/components/callout';
|
import { Callout } from 'fumadocs-ui/components/callout';
|
||||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||||
|
|
||||||
|
<EnvelopeWarning />
|
||||||
|
|
||||||
<Callout type="warn">
|
<Callout type="warn">
|
||||||
This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference,
|
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).
|
see the [OpenAPI Reference](https://openapi.documenso.com).
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ description: Complete reference for the Documenso REST API.
|
|||||||
|
|
||||||
import { Callout } from 'fumadocs-ui/components/callout';
|
import { Callout } from 'fumadocs-ui/components/callout';
|
||||||
|
|
||||||
|
<EnvelopeWarning />
|
||||||
|
|
||||||
<Callout type="warn">
|
<Callout type="warn">
|
||||||
The guides below cover common API patterns but may not reflect the latest endpoints or parameters.
|
The guides below cover common API patterns but may not reflect the latest endpoints or parameters.
|
||||||
For an always up-to-date reference, see the [OpenAPI Reference](https://openapi.documenso.com).
|
For an always up-to-date reference, see the [OpenAPI Reference](https://openapi.documenso.com).
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"teams",
|
"teams",
|
||||||
"rate-limits",
|
"rate-limits",
|
||||||
"versioning",
|
"versioning",
|
||||||
|
"migrate-to-envelopes",
|
||||||
"developer-mode",
|
"developer-mode",
|
||||||
"common-errors"
|
"common-errors"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
---
|
||||||
|
title: Migrating to Envelopes
|
||||||
|
description: Why Documenso unified documents and templates into envelopes, and how to migrate from the deprecated document and template create endpoints.
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
|
||||||
|
import { Callout } from 'fumadocs-ui/components/callout';
|
||||||
|
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||||
|
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The following items have been deprecated and will be removed on the <strong>1st of March 2027</strong>:
|
||||||
|
|
||||||
|
- <strong>API V1</strong>
|
||||||
|
- <strong>A subset of SDK/API V2 endpoints</strong>
|
||||||
|
- <strong>Legacy documents and templates</strong>
|
||||||
|
- <strong>EmbedCreateDocumentV1</strong>
|
||||||
|
- <strong>EmbedCreateTemplateV1</strong>
|
||||||
|
- <strong>EmbedUpdateDocumentV1</strong>
|
||||||
|
- <strong>EmbedUpdateTemplateV1</strong>
|
||||||
|
|
||||||
|
The beta endpoint `/api/v2-beta` will also be removed. Use `/api/v2` instead, which is a drop-in replacement.
|
||||||
|
|
||||||
|
Nothing breaks before 1st of March 2027, so you can migrate at your own pace.
|
||||||
|
|
||||||
|
## What are legacy documents and templates
|
||||||
|
|
||||||
|
These are documents and templates created by the following endpoints:
|
||||||
|
|
||||||
|
- `POST /api/v2/document/create`
|
||||||
|
- `POST /api/v2/document/create/beta`
|
||||||
|
- `POST /api/v2/template/create`
|
||||||
|
- `POST /api/v2/template/create/beta`
|
||||||
|
- `POST /api/v1/documents`
|
||||||
|
- `POST /api/v1/templates`
|
||||||
|
- `POST /api/v1/templates/create-document`
|
||||||
|
- `POST /api/v1/templates/generate-document`
|
||||||
|
|
||||||
|
## What replaces legacy documents and templates
|
||||||
|
|
||||||
|
At the end of 2025 we introduced a unified system for documents and templates, called <strong>envelopes</strong>.
|
||||||
|
|
||||||
|
We still reference documents and templates throughout the documentation and application to distinguish them, but internally they are envelopes.
|
||||||
|
|
||||||
|
Moving to the envelope system gives you:
|
||||||
|
|
||||||
|
- **Multiple PDFs in one envelope.** Send several documents to sign in a single request.
|
||||||
|
- **One API for documents and templates.** Learn one set of endpoints instead of two misaligned ones.
|
||||||
|
- **A better editor and signing experience** for you and your recipients.
|
||||||
|
|
||||||
|
## How to migrate
|
||||||
|
|
||||||
|
{/* prettier-ignore */}
|
||||||
|
<Steps>
|
||||||
|
<Step>
|
||||||
|
### Switch to the envelope endpoints
|
||||||
|
|
||||||
|
Replace each deprecated endpoint with its `/api/v2/envelope/*` equivalent from the [mapping tables](#endpoint-mapping-reference) below.
|
||||||
|
</Step>
|
||||||
|
<Step>
|
||||||
|
### Set the envelope `type` on create
|
||||||
|
|
||||||
|
A single endpoint, `POST /api/v2/envelope/create`, can create both documents and templates. Set `type` to `DOCUMENT` or `TEMPLATE`. You can now upload more than one PDF using the `files` field.
|
||||||
|
</Step>
|
||||||
|
<Step>
|
||||||
|
### Update how you store IDs
|
||||||
|
|
||||||
|
Envelope IDs are **strings** (for example `envelope_abc123`), not numbers. Update any code that stores, parses, or compares IDs.
|
||||||
|
</Step>
|
||||||
|
<Step>
|
||||||
|
### Test, then remove the old calls
|
||||||
|
|
||||||
|
Verify the new flow against your account, then delete the deprecated calls.
|
||||||
|
</Step>
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
The main data differences are as follows:
|
||||||
|
- ID format changed from number to string (e.g. `42` to `envelope_abc123`)
|
||||||
|
- pageNumber becomes page
|
||||||
|
- pageX becomes positionX
|
||||||
|
- pageY becomes positionY
|
||||||
|
|
||||||
|
See the [Documents API](/docs/developers/api/documents) and [Templates API](/docs/developers/api/templates) for the full envelope reference.
|
||||||
|
|
||||||
|
### Deprecated V1 API Endpoints
|
||||||
|
|
||||||
|
Full reference in the [V1 OpenAPI reference](https://openapi-v1.documenso.com).
|
||||||
|
|
||||||
|
| Deprecated endpoint | Replacement |
|
||||||
|
| -------------------------------------------------------- | ----------------------------------------------------- |
|
||||||
|
| `GET /api/v1/documents` | `GET /api/v2/envelope` |
|
||||||
|
| `GET /api/v1/documents/{id}` | `GET /api/v2/envelope/{envelopeId}` |
|
||||||
|
| `POST /api/v1/documents` | `POST /api/v2/envelope/create` |
|
||||||
|
| `POST /api/v1/documents/{id}/send` | `POST /api/v2/envelope/distribute` |
|
||||||
|
| `POST /api/v1/documents/{id}/resend` | `POST /api/v2/envelope/redistribute` |
|
||||||
|
| `DELETE /api/v1/documents/{id}` | `POST /api/v2/envelope/delete` |
|
||||||
|
| `GET /api/v1/documents/{id}/download` | `GET /api/v2/envelope/item/{envelopeItemId}/download` |
|
||||||
|
| `POST /api/v1/documents/{id}/recipients` | `POST /api/v2/envelope/recipient/create-many` |
|
||||||
|
| `PATCH /api/v1/documents/{id}/recipients/{recipientId}` | `POST /api/v2/envelope/recipient/update-many` |
|
||||||
|
| `DELETE /api/v1/documents/{id}/recipients/{recipientId}` | `POST /api/v2/envelope/recipient/delete` |
|
||||||
|
| `POST /api/v1/documents/{id}/fields` | `POST /api/v2/envelope/field/create-many` |
|
||||||
|
| `PATCH /api/v1/documents/{id}/fields/{fieldId}` | `POST /api/v2/envelope/field/update-many` |
|
||||||
|
| `DELETE /api/v1/documents/{id}/fields/{fieldId}` | `POST /api/v2/envelope/field/delete` |
|
||||||
|
| `GET /api/v1/templates` | `GET /api/v2/envelope` (with `type=TEMPLATE`) |
|
||||||
|
| `GET /api/v1/templates/{id}` | `GET /api/v2/envelope/{envelopeId}` |
|
||||||
|
| `POST /api/v1/templates` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
|
||||||
|
| `DELETE /api/v1/templates/{id}` | `POST /api/v2/envelope/delete` |
|
||||||
|
| `POST /api/v1/templates/{templateId}/create-document` | `POST /api/v2/envelope/use` |
|
||||||
|
| `POST /api/v1/templates/{templateId}/generate-document` | `POST /api/v2/envelope/use` |
|
||||||
|
|
||||||
|
### Deprecated V2 API Endpoints
|
||||||
|
|
||||||
|
Full reference in the [V2 OpenAPI reference](https://openapi.documenso.com).
|
||||||
|
|
||||||
|
#### Documents
|
||||||
|
|
||||||
|
| Deprecated endpoint | Replacement |
|
||||||
|
| ------------------------------------------------- | ----------------------------------------------------- |
|
||||||
|
| `GET /api/v2/document` | `GET /api/v2/envelope` |
|
||||||
|
| `GET /api/v2/document/{documentId}` | `GET /api/v2/envelope/{envelopeId}` |
|
||||||
|
| `POST /api/v2/document/get-many` | `POST /api/v2/envelope/get-many` |
|
||||||
|
| `POST /api/v2/document/create` | `POST /api/v2/envelope/create` |
|
||||||
|
| `POST /api/v2/document/create/beta` | `POST /api/v2/envelope/create` |
|
||||||
|
| `POST /api/v2/document/update` | `POST /api/v2/envelope/update` |
|
||||||
|
| `POST /api/v2/document/delete` | `POST /api/v2/envelope/delete` |
|
||||||
|
| `POST /api/v2/document/duplicate` | `POST /api/v2/envelope/duplicate` |
|
||||||
|
| `POST /api/v2/document/distribute` | `POST /api/v2/envelope/distribute` |
|
||||||
|
| `POST /api/v2/document/redistribute` | `POST /api/v2/envelope/redistribute` |
|
||||||
|
| `GET /api/v2/document/attachment` | `GET /api/v2/envelope/attachment` |
|
||||||
|
| `POST /api/v2/document/attachment/create` | `POST /api/v2/envelope/attachment/create` |
|
||||||
|
| `POST /api/v2/document/attachment/update` | `POST /api/v2/envelope/attachment/update` |
|
||||||
|
| `POST /api/v2/document/attachment/delete` | `POST /api/v2/envelope/attachment/delete` |
|
||||||
|
| `GET /api/v2/document/{documentId}/download` | `GET /api/v2/envelope/item/{envelopeItemId}/download` |
|
||||||
|
| `GET /api/v2/document/{documentId}/download-beta` | `GET /api/v2/envelope/item/{envelopeItemId}/download` |
|
||||||
|
|
||||||
|
#### Templates
|
||||||
|
|
||||||
|
| Deprecated endpoint | Replacement |
|
||||||
|
| ------------------------------------- | ------------------------------------------------ |
|
||||||
|
| `GET /api/v2/template` | `GET /api/v2/envelope` (with `type=TEMPLATE`) |
|
||||||
|
| `GET /api/v2/template/{templateId}` | `GET /api/v2/envelope/{envelopeId}` |
|
||||||
|
| `POST /api/v2/template/get-many` | `POST /api/v2/envelope/get-many` |
|
||||||
|
| `POST /api/v2/template/create` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
|
||||||
|
| `POST /api/v2/template/create/beta` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
|
||||||
|
| `POST /api/v2/template/update` | `POST /api/v2/envelope/update` |
|
||||||
|
| `POST /api/v2/template/duplicate` | `POST /api/v2/envelope/duplicate` |
|
||||||
|
| `POST /api/v2/template/delete` | `POST /api/v2/envelope/delete` |
|
||||||
|
| `POST /api/v2/template/use` | `POST /api/v2/envelope/use` |
|
||||||
|
| `POST /api/v2/template/direct/create` | **Pending replacement** |
|
||||||
|
| `POST /api/v2/template/direct/delete` | **Pending replacement** |
|
||||||
|
| `POST /api/v2/template/direct/toggle` | **Pending replacement** |
|
||||||
|
|
||||||
|
#### Document fields
|
||||||
|
|
||||||
|
| Deprecated endpoint | Replacement |
|
||||||
|
| ----------------------------------------- | ----------------------------------------- |
|
||||||
|
| `GET /api/v2/document/field/{fieldId}` | `GET /api/v2/envelope/field/{fieldId}` |
|
||||||
|
| `POST /api/v2/document/field/create` | `POST /api/v2/envelope/field/create-many` |
|
||||||
|
| `POST /api/v2/document/field/create-many` | `POST /api/v2/envelope/field/create-many` |
|
||||||
|
| `POST /api/v2/document/field/update` | `POST /api/v2/envelope/field/update-many` |
|
||||||
|
| `POST /api/v2/document/field/update-many` | `POST /api/v2/envelope/field/update-many` |
|
||||||
|
| `POST /api/v2/document/field/delete` | `POST /api/v2/envelope/field/delete` |
|
||||||
|
|
||||||
|
#### Template fields
|
||||||
|
|
||||||
|
| Deprecated endpoint | Replacement |
|
||||||
|
| ----------------------------------------- | ----------------------------------------- |
|
||||||
|
| `GET /api/v2/template/field/{fieldId}` | `GET /api/v2/envelope/field/{fieldId}` |
|
||||||
|
| `POST /api/v2/template/field/create` | `POST /api/v2/envelope/field/create-many` |
|
||||||
|
| `POST /api/v2/template/field/create-many` | `POST /api/v2/envelope/field/create-many` |
|
||||||
|
| `POST /api/v2/template/field/update` | `POST /api/v2/envelope/field/update-many` |
|
||||||
|
| `POST /api/v2/template/field/update-many` | `POST /api/v2/envelope/field/update-many` |
|
||||||
|
| `POST /api/v2/template/field/delete` | `POST /api/v2/envelope/field/delete` |
|
||||||
|
|
||||||
|
#### Document recipients
|
||||||
|
|
||||||
|
| Deprecated endpoint | Replacement |
|
||||||
|
| ---------------------------------------------- | ---------------------------------------------- |
|
||||||
|
| `GET /api/v2/document/recipient/{recipientId}` | `GET /api/v2/envelope/recipient/{recipientId}` |
|
||||||
|
| `POST /api/v2/document/recipient/create` | `POST /api/v2/envelope/recipient/create-many` |
|
||||||
|
| `POST /api/v2/document/recipient/create-many` | `POST /api/v2/envelope/recipient/create-many` |
|
||||||
|
| `POST /api/v2/document/recipient/update` | `POST /api/v2/envelope/recipient/update-many` |
|
||||||
|
| `POST /api/v2/document/recipient/update-many` | `POST /api/v2/envelope/recipient/update-many` |
|
||||||
|
| `POST /api/v2/document/recipient/delete` | `POST /api/v2/envelope/recipient/delete` |
|
||||||
|
|
||||||
|
#### Template recipients
|
||||||
|
|
||||||
|
| Deprecated endpoint | Replacement |
|
||||||
|
| ---------------------------------------------- | ---------------------------------------------- |
|
||||||
|
| `GET /api/v2/template/recipient/{recipientId}` | `GET /api/v2/envelope/recipient/{recipientId}` |
|
||||||
|
| `POST /api/v2/template/recipient/create` | `POST /api/v2/envelope/recipient/create-many` |
|
||||||
|
| `POST /api/v2/template/recipient/create-many` | `POST /api/v2/envelope/recipient/create-many` |
|
||||||
|
| `POST /api/v2/template/recipient/update` | `POST /api/v2/envelope/recipient/update-many` |
|
||||||
|
| `POST /api/v2/template/recipient/update-many` | `POST /api/v2/envelope/recipient/update-many` |
|
||||||
|
| `POST /api/v2/template/recipient/delete` | `POST /api/v2/envelope/recipient/delete` |
|
||||||
|
|
||||||
|
### Embedding components
|
||||||
|
|
||||||
|
| Deprecated component | Replacement |
|
||||||
|
| ----------------------- | --------------------- |
|
||||||
|
| `EmbedCreateDocumentV1` | `EmbedCreateEnvelope` |
|
||||||
|
| `EmbedCreateTemplateV1` | `EmbedCreateEnvelope` |
|
||||||
|
| `EmbedUpdateDocumentV1` | `EmbedUpdateEnvelope` |
|
||||||
|
| `EmbedUpdateTemplateV1` | `EmbedUpdateEnvelope` |
|
||||||
|
|
||||||
|
See the [embedding guide](/docs/developers/embedding) for the envelope components.
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
<Accordions>
|
||||||
|
<Accordion title="What happens on 1 March 2027?">
|
||||||
|
The deprecated V1 API, the V2 endpoints listed above, and the V1 embedding components are removed.
|
||||||
|
Requests to them will fail, so migrate to the envelope API before that date.
|
||||||
|
</Accordion>
|
||||||
|
<Accordion title="Will my existing documents and templates keep working?">
|
||||||
|
Yes. Documents and templates you already created remain in your account and continue to work. They will automatically be converted to envelopes. Only
|
||||||
|
the deprecated endpoints you call are going away. Your data is not deleted.
|
||||||
|
</Accordion>
|
||||||
|
<Accordion title="Do I need a new API token?">
|
||||||
|
No. Authentication is unchanged. The same API token works for the envelope endpoints under
|
||||||
|
`https://app.documenso.com/api/v2`.
|
||||||
|
</Accordion>
|
||||||
|
<Accordion title="What is the difference between a document and a template now?">
|
||||||
|
Both are envelopes, distinguished by a `type` field of `DOCUMENT` or `TEMPLATE`. They share the same
|
||||||
|
endpoints, recipients, fields, and attachments.
|
||||||
|
</Accordion>
|
||||||
|
<Accordion title="I use an official SDK, what should I do?">
|
||||||
|
The function calls to the legacy endpoints will break on the 1st of March 2027. Update to the latest SDK version and switch to its envelope methods.
|
||||||
|
The deprecated document and template methods map to the envelope endpoints in the tables above.
|
||||||
|
</Accordion>
|
||||||
|
<Accordion title="I need more time or help migrating">
|
||||||
|
Reach out to [support@documenso.com](mailto:support@documenso.com) with your use case and we will
|
||||||
|
help you plan the migration.
|
||||||
|
</Accordion>
|
||||||
|
</Accordions>
|
||||||
|
|
||||||
|
## Getting help
|
||||||
|
|
||||||
|
- [V2 OpenAPI reference](https://openapi.documenso.com): the up-to-date envelope API.
|
||||||
|
- [V1 OpenAPI reference](https://openapi-v1.documenso.com): the deprecated V1 API.
|
||||||
|
- [support@documenso.com](mailto:support@documenso.com): migration questions and extensions.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Documents API](/docs/developers/api/documents): create and manage envelopes
|
||||||
|
- [Templates API](/docs/developers/api/templates): work with templates and direct links
|
||||||
|
- [Fields API](/docs/developers/api/fields) and [Recipients API](/docs/developers/api/recipients)
|
||||||
|
- [API Versioning](/docs/developers/api/versioning): how Documenso versions the public API
|
||||||
@@ -11,10 +11,21 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
|
|||||||
|
|
||||||
## HTTP Rate Limits
|
## HTTP Rate Limits
|
||||||
|
|
||||||
**Limit:** 100 requests per minute per IP address
|
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
|
**Response:** 429 Too Many Requests
|
||||||
|
|
||||||
### Rate Limit Response
|
<Callout type="info">
|
||||||
|
This is the global per-IP ceiling. Your organisation may have its own rate limits configured below
|
||||||
|
this value, in which case you can be rate-limited before reaching the global limit.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
### Global per-IP 429 Response
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -22,10 +33,22 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
<Callout type="warn">
|
### Rate Limit Headers
|
||||||
No rate limit headers are currently provided. When you receive a 429 response, wait at least 60
|
|
||||||
seconds before retrying.
|
Responses from `/api/v1/*`, `/api/v2/*`, and `/api/v2-beta/*` include these headers. The only
|
||||||
</Callout>
|
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
|
## Resource Limits
|
||||||
|
|
||||||
@@ -39,24 +62,55 @@ Beyond HTTP rate limits, your account has usage limits based on your subscriptio
|
|||||||
| Total Recipients | 10 | Unlimited | Unlimited | Unlimited |
|
| Total Recipients | 10 | Unlimited | Unlimited | Unlimited |
|
||||||
| Direct Templates | 3 | 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
|
```json
|
||||||
{
|
{
|
||||||
"error": "You have reached your document limit for this month. Please upgrade your plan.",
|
"message": "Too many requests, please try again later. Contact support if you require higher limits."
|
||||||
"code": "LIMIT_EXCEEDED",
|
|
||||||
"statusCode": 400
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
## Error Codes
|
||||||
|
|
||||||
| Code | Status | Description |
|
| 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 |
|
| `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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ description: Create documents from reusable templates via API.
|
|||||||
import { Callout } from 'fumadocs-ui/components/callout';
|
import { Callout } from 'fumadocs-ui/components/callout';
|
||||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||||
|
|
||||||
|
<EnvelopeWarning />
|
||||||
|
|
||||||
<Callout type="warn">
|
<Callout type="warn">
|
||||||
This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference,
|
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).
|
see the [OpenAPI Reference](https://openapi.documenso.com).
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ description: Versioning information for the Documenso public API.
|
|||||||
|
|
||||||
import { Callout } from 'fumadocs-ui/components/callout';
|
import { Callout } from 'fumadocs-ui/components/callout';
|
||||||
|
|
||||||
|
<EnvelopeWarning />
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Documenso uses API versioning to manage changes to the public API. This allows us to introduce new features, fix bugs, and make other changes without breaking existing integrations.
|
Documenso uses API versioning to manage changes to the public API. This allows us to introduce new features, fix bugs, and make other changes without breaking existing integrations.
|
||||||
@@ -19,7 +21,16 @@ Also, we may deprecate certain features or endpoints in the API. When we depreca
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Documents, Templates, and Envelopes
|
||||||
|
|
||||||
|
Documenso has unified documents and templates into a single resource called an **envelope**. New integrations should create documents and templates through the `/envelope/*` endpoints. The `POST /document/create` and `POST /template/create` endpoints (including their `/beta` variants) are deprecated in favor of `POST /envelope/create`.
|
||||||
|
|
||||||
|
See [Migrating to the Envelope API](/docs/developers/api/migrate-to-envelopes) for the rationale and step-by-step migration examples.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## See Also
|
## See Also
|
||||||
|
|
||||||
|
- [Migrating to the Envelope API](/docs/developers/api/migrate-to-envelopes) - Move from the document and template create endpoints
|
||||||
- [Authentication](/docs/developers/getting-started/authentication) - API authentication guide
|
- [Authentication](/docs/developers/getting-started/authentication) - API authentication guide
|
||||||
- [Rate Limits](/docs/developers/api/rate-limits) - API rate limit details
|
- [Rate Limits](/docs/developers/api/rate-limits) - API rate limit details
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { Callout } from 'fumadocs-ui/components/callout';
|
|||||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||||
|
|
||||||
|
<EnvelopeWarning />
|
||||||
|
|
||||||
## Workflow 1: Send a Document for Signature
|
## Workflow 1: Send a Document for Signature
|
||||||
|
|
||||||
The most common workflow: upload a PDF, add recipients with signature fields, and send for signing.
|
The most common workflow: upload a PDF, add recipients with signature fields, and send for signing.
|
||||||
@@ -472,7 +474,7 @@ Send the same document to multiple recipients in parallel. Useful for policy ack
|
|||||||
<code>distributeDocument: true</code>
|
<code>distributeDocument: true</code>
|
||||||
</Step>
|
</Step>
|
||||||
<Step>
|
<Step>
|
||||||
Process in batches with a short delay to respect rate limits (e.g. 100 requests/minute)
|
Process in batches with a short delay to respect rate limits (e.g. 1000 requests/minute)
|
||||||
</Step>
|
</Step>
|
||||||
</Steps>
|
</Steps>
|
||||||
|
|
||||||
@@ -638,8 +640,8 @@ done
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<Callout type="info">
|
<Callout type="info">
|
||||||
The API allows 100 requests per minute. For large batches, implement rate limiting with delays
|
The API allows 1000 requests per minute (your organisation may have its own lower limit). For large
|
||||||
between requests to avoid hitting limits.
|
batches, implement rate limiting with delays between requests to avoid hitting limits.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -998,9 +1000,12 @@ async function fetchWithRetry(
|
|||||||
// Retry on rate limit
|
// Retry on rate limit
|
||||||
if (response.status === 429) {
|
if (response.status === 429) {
|
||||||
const retryAfter = response.headers.get('Retry-After');
|
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...`);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ title: Examples
|
|||||||
description: Common integration patterns and end-to-end workflows.
|
description: Common integration patterns and end-to-end workflows.
|
||||||
---
|
---
|
||||||
|
|
||||||
|
<EnvelopeWarning />
|
||||||
|
|
||||||
<Cards>
|
<Cards>
|
||||||
<Card
|
<Card
|
||||||
title="Common Workflows"
|
title="Common Workflows"
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
|
|||||||
import { Callout } from 'fumadocs-ui/components/callout';
|
import { Callout } from 'fumadocs-ui/components/callout';
|
||||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||||
|
|
||||||
|
<EnvelopeWarning />
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- A Documenso account (cloud or self-hosted)
|
- A Documenso account (cloud or self-hosted)
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { Callout } from 'fumadocs-ui/components/callout';
|
|||||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||||
|
|
||||||
|
<EnvelopeWarning />
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
Before starting, you need:
|
Before starting, you need:
|
||||||
@@ -483,7 +485,7 @@ The API returns standard HTTP status codes and JSON error responses:
|
|||||||
|
|
||||||
### Handling Rate Limits
|
### Handling Rate Limits
|
||||||
|
|
||||||
The API allows 100 requests per minute per IP address. When rate limited, wait at least 60 seconds before retrying:
|
The API allows 1000 requests per minute per IP address. Your organisation may have its own lower rate limits. When rate limited, wait at least 60 seconds before retrying:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
async function fetchWithRetry(url, options, maxRetries = 3) {
|
async function fetchWithRetry(url, options, maxRetries = 3) {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ title: Getting Started
|
|||||||
description: Get your API key and make your first API call.
|
description: Get your API key and make your first API call.
|
||||||
---
|
---
|
||||||
|
|
||||||
|
<EnvelopeWarning />
|
||||||
|
|
||||||
<Cards>
|
<Cards>
|
||||||
<Card
|
<Card
|
||||||
title="Authentication"
|
title="Authentication"
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ title: Developer Guide
|
|||||||
description: Integrate Documenso into your applications using the REST API, webhooks, and embedding options.
|
description: Integrate Documenso into your applications using the REST API, webhooks, and embedding options.
|
||||||
---
|
---
|
||||||
|
|
||||||
|
<EnvelopeWarning />
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
<Cards>
|
<Cards>
|
||||||
|
|||||||
@@ -33,13 +33,14 @@ All webhook events share a common structure:
|
|||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ---------------- | --------- | ------------------------------------------------------ |
|
| ---------------- | --------- | ------------------------------------------------------ |
|
||||||
| `id` | number | Document or template ID |
|
| `id` | number | Legacy numeric v1 document or template ID |
|
||||||
|
| `envelopeId` | string | Canonical v2 identifier (`envelope_` + 16 characters) |
|
||||||
| `externalId` | string? | External identifier for integration |
|
| `externalId` | string? | External identifier for integration |
|
||||||
| `userId` | number | Owner's user ID |
|
| `userId` | number | Owner's user ID |
|
||||||
| `authOptions` | object? | Document-level authentication options |
|
| `authOptions` | object? | Document-level authentication options |
|
||||||
| `formValues` | object? | PDF form values associated with the document |
|
| `formValues` | object? | PDF form values associated with the document |
|
||||||
| `title` | string | Document or template title |
|
| `title` | string | Document or template title |
|
||||||
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED` |
|
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, `CANCELLED` |
|
||||||
| `visibility` | string | Document visibility setting |
|
| `visibility` | string | Document visibility setting |
|
||||||
| `createdAt` | datetime | Document creation timestamp |
|
| `createdAt` | datetime | Document creation timestamp |
|
||||||
| `updatedAt` | datetime | Last modification timestamp |
|
| `updatedAt` | datetime | Last modification timestamp |
|
||||||
@@ -47,8 +48,8 @@ All webhook events share a common structure:
|
|||||||
| `deletedAt` | datetime? | Deletion timestamp |
|
| `deletedAt` | datetime? | Deletion timestamp |
|
||||||
| `teamId` | number? | Team ID if document belongs to a team |
|
| `teamId` | number? | Team ID if document belongs to a team |
|
||||||
| `templateId` | number? | Template ID if created from a template |
|
| `templateId` | number? | Template ID if created from a template |
|
||||||
| `source` | string | Source: `DOCUMENT` or `TEMPLATE` |
|
| `source` | string | Source: `DOCUMENT`, `TEMPLATE`, or `TEMPLATE_DIRECT_LINK` |
|
||||||
| `documentMeta` | object | Document metadata (subject, message, signing options) |
|
| `documentMeta` | object? | Nullable document metadata (subject, message, signing options) |
|
||||||
| `recipients` | array | List of recipient objects |
|
| `recipients` | array | List of recipient objects |
|
||||||
| `Recipient` | array | List of recipient objects (legacy, same as recipients) |
|
| `Recipient` | array | List of recipient objects (legacy, same as recipients) |
|
||||||
|
|
||||||
@@ -60,7 +61,6 @@ All webhook events share a common structure:
|
|||||||
| `subject` | string? | Email subject line |
|
| `subject` | string? | Email subject line |
|
||||||
| `message` | string? | Email message body |
|
| `message` | string? | Email message body |
|
||||||
| `timezone` | string | Timezone for date display |
|
| `timezone` | string | Timezone for date display |
|
||||||
| `password` | string? | Document access password (if set) |
|
|
||||||
| `dateFormat` | string | Date format string |
|
| `dateFormat` | string | Date format string |
|
||||||
| `redirectUrl` | string? | URL to redirect after signing |
|
| `redirectUrl` | string? | URL to redirect after signing |
|
||||||
| `signingOrder` | string | `PARALLEL` or `SEQUENTIAL` |
|
| `signingOrder` | string | `PARALLEL` or `SEQUENTIAL` |
|
||||||
@@ -77,8 +77,9 @@ All webhook events share a common structure:
|
|||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ---------------------- | --------- | ------------------------------------------ |
|
| ---------------------- | --------- | ------------------------------------------ |
|
||||||
| `id` | number | Recipient ID |
|
| `id` | number | Recipient ID |
|
||||||
| `documentId` | number? | Parent document ID |
|
| `envelopeId` | string | Canonical parent envelope ID |
|
||||||
| `templateId` | number? | Template ID if created from a template |
|
| `documentId` | number? | Legacy parent document ID; null for templates |
|
||||||
|
| `templateId` | number? | Legacy parent template ID; null for documents |
|
||||||
| `email` | string | Recipient email address |
|
| `email` | string | Recipient email address |
|
||||||
| `name` | string | Recipient name |
|
| `name` | string | Recipient name |
|
||||||
| `token` | string | Unique signing token |
|
| `token` | string | Unique signing token |
|
||||||
@@ -94,6 +95,8 @@ All webhook events share a common structure:
|
|||||||
| `sendStatus` | string | `NOT_SENT` or `SENT` |
|
| `sendStatus` | string | `NOT_SENT` or `SENT` |
|
||||||
| `rejectionReason` | string? | Reason if recipient rejected |
|
| `rejectionReason` | string? | Reason if recipient rejected |
|
||||||
|
|
||||||
|
Use `recipient.envelopeId` as the reliable parent link. The legacy `documentId` and `templateId` fields depend on the parent envelope type, so one of them is always null.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Document Lifecycle Events
|
## Document Lifecycle Events
|
||||||
@@ -111,6 +114,7 @@ Triggered when a new document is created.
|
|||||||
"event": "DOCUMENT_CREATED",
|
"event": "DOCUMENT_CREATED",
|
||||||
"payload": {
|
"payload": {
|
||||||
"id": 10,
|
"id": 10,
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"externalId": null,
|
"externalId": null,
|
||||||
"userId": 1,
|
"userId": 1,
|
||||||
"authOptions": null,
|
"authOptions": null,
|
||||||
@@ -129,9 +133,8 @@ Triggered when a new document is created.
|
|||||||
"id": "doc_meta_123",
|
"id": "doc_meta_123",
|
||||||
"subject": "Please sign this document",
|
"subject": "Please sign this document",
|
||||||
"message": "Hello, please review and sign this document.",
|
"message": "Hello, please review and sign this document.",
|
||||||
"timezone": "UTC",
|
"timezone": "Etc/UTC",
|
||||||
"password": null,
|
"dateFormat": "yyyy-MM-dd hh:mm a",
|
||||||
"dateFormat": "MM/DD/YYYY",
|
|
||||||
"redirectUrl": null,
|
"redirectUrl": null,
|
||||||
"signingOrder": "PARALLEL",
|
"signingOrder": "PARALLEL",
|
||||||
"allowDictateNextSigner": false,
|
"allowDictateNextSigner": false,
|
||||||
@@ -145,6 +148,7 @@ Triggered when a new document is created.
|
|||||||
"recipients": [
|
"recipients": [
|
||||||
{
|
{
|
||||||
"id": 52,
|
"id": 52,
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"documentId": 10,
|
"documentId": 10,
|
||||||
"templateId": null,
|
"templateId": null,
|
||||||
"email": "signer@example.com",
|
"email": "signer@example.com",
|
||||||
@@ -166,6 +170,7 @@ Triggered when a new document is created.
|
|||||||
"Recipient": [
|
"Recipient": [
|
||||||
{
|
{
|
||||||
"id": 52,
|
"id": 52,
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"documentId": 10,
|
"documentId": 10,
|
||||||
"templateId": null,
|
"templateId": null,
|
||||||
"email": "signer@example.com",
|
"email": "signer@example.com",
|
||||||
@@ -203,6 +208,7 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
|
|||||||
"event": "DOCUMENT_SENT",
|
"event": "DOCUMENT_SENT",
|
||||||
"payload": {
|
"payload": {
|
||||||
"id": 10,
|
"id": 10,
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"externalId": null,
|
"externalId": null,
|
||||||
"userId": 1,
|
"userId": 1,
|
||||||
"authOptions": null,
|
"authOptions": null,
|
||||||
@@ -221,9 +227,8 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
|
|||||||
"id": "doc_meta_123",
|
"id": "doc_meta_123",
|
||||||
"subject": "Please sign this document",
|
"subject": "Please sign this document",
|
||||||
"message": "Hello, please review and sign this document.",
|
"message": "Hello, please review and sign this document.",
|
||||||
"timezone": "UTC",
|
"timezone": "Etc/UTC",
|
||||||
"password": null,
|
"dateFormat": "yyyy-MM-dd hh:mm a",
|
||||||
"dateFormat": "MM/DD/YYYY",
|
|
||||||
"redirectUrl": null,
|
"redirectUrl": null,
|
||||||
"signingOrder": "PARALLEL",
|
"signingOrder": "PARALLEL",
|
||||||
"allowDictateNextSigner": false,
|
"allowDictateNextSigner": false,
|
||||||
@@ -237,6 +242,7 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
|
|||||||
"recipients": [
|
"recipients": [
|
||||||
{
|
{
|
||||||
"id": 52,
|
"id": 52,
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"documentId": 10,
|
"documentId": 10,
|
||||||
"templateId": null,
|
"templateId": null,
|
||||||
"email": "signer@example.com",
|
"email": "signer@example.com",
|
||||||
@@ -258,6 +264,7 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
|
|||||||
"Recipient": [
|
"Recipient": [
|
||||||
{
|
{
|
||||||
"id": 52,
|
"id": 52,
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"documentId": 10,
|
"documentId": 10,
|
||||||
"templateId": null,
|
"templateId": null,
|
||||||
"email": "signer@example.com",
|
"email": "signer@example.com",
|
||||||
@@ -295,12 +302,14 @@ The recipient's `readStatus` changes to `OPENED`.
|
|||||||
"event": "DOCUMENT_OPENED",
|
"event": "DOCUMENT_OPENED",
|
||||||
"payload": {
|
"payload": {
|
||||||
"id": 10,
|
"id": 10,
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"status": "PENDING",
|
"status": "PENDING",
|
||||||
"title": "contract.pdf",
|
"title": "contract.pdf",
|
||||||
"source": "DOCUMENT",
|
"source": "DOCUMENT",
|
||||||
"recipients": [
|
"recipients": [
|
||||||
{
|
{
|
||||||
"id": 52,
|
"id": 52,
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"email": "signer@example.com",
|
"email": "signer@example.com",
|
||||||
"name": "John Doe",
|
"name": "John Doe",
|
||||||
"role": "SIGNER",
|
"role": "SIGNER",
|
||||||
@@ -328,6 +337,7 @@ The recipient's `signingStatus` changes to `SIGNED` and `signedAt` is populated.
|
|||||||
"event": "DOCUMENT_SIGNED",
|
"event": "DOCUMENT_SIGNED",
|
||||||
"payload": {
|
"payload": {
|
||||||
"id": 10,
|
"id": 10,
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"status": "COMPLETED",
|
"status": "COMPLETED",
|
||||||
"title": "contract.pdf",
|
"title": "contract.pdf",
|
||||||
"source": "DOCUMENT",
|
"source": "DOCUMENT",
|
||||||
@@ -335,6 +345,7 @@ The recipient's `signingStatus` changes to `SIGNED` and `signedAt` is populated.
|
|||||||
"recipients": [
|
"recipients": [
|
||||||
{
|
{
|
||||||
"id": 51,
|
"id": 51,
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"email": "signer@example.com",
|
"email": "signer@example.com",
|
||||||
"name": "John Doe",
|
"name": "John Doe",
|
||||||
"role": "SIGNER",
|
"role": "SIGNER",
|
||||||
@@ -361,12 +372,14 @@ Triggered when an individual recipient completes their required action (signing,
|
|||||||
"event": "DOCUMENT_RECIPIENT_COMPLETED",
|
"event": "DOCUMENT_RECIPIENT_COMPLETED",
|
||||||
"payload": {
|
"payload": {
|
||||||
"id": 10,
|
"id": 10,
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"status": "PENDING",
|
"status": "PENDING",
|
||||||
"title": "contract.pdf",
|
"title": "contract.pdf",
|
||||||
"source": "DOCUMENT",
|
"source": "DOCUMENT",
|
||||||
"recipients": [
|
"recipients": [
|
||||||
{
|
{
|
||||||
"id": 52,
|
"id": 52,
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"email": "signer@example.com",
|
"email": "signer@example.com",
|
||||||
"name": "John Doe",
|
"name": "John Doe",
|
||||||
"role": "SIGNER",
|
"role": "SIGNER",
|
||||||
@@ -395,6 +408,7 @@ The document status changes to `COMPLETED` and `completedAt` is set.
|
|||||||
"event": "DOCUMENT_COMPLETED",
|
"event": "DOCUMENT_COMPLETED",
|
||||||
"payload": {
|
"payload": {
|
||||||
"id": 10,
|
"id": 10,
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"externalId": null,
|
"externalId": null,
|
||||||
"userId": 1,
|
"userId": 1,
|
||||||
"authOptions": null,
|
"authOptions": null,
|
||||||
@@ -413,9 +427,8 @@ The document status changes to `COMPLETED` and `completedAt` is set.
|
|||||||
"id": "doc_meta_123",
|
"id": "doc_meta_123",
|
||||||
"subject": "Please sign this document",
|
"subject": "Please sign this document",
|
||||||
"message": "Hello, please review and sign this document.",
|
"message": "Hello, please review and sign this document.",
|
||||||
"timezone": "UTC",
|
"timezone": "Etc/UTC",
|
||||||
"password": null,
|
"dateFormat": "yyyy-MM-dd hh:mm a",
|
||||||
"dateFormat": "MM/DD/YYYY",
|
|
||||||
"redirectUrl": null,
|
"redirectUrl": null,
|
||||||
"signingOrder": "PARALLEL",
|
"signingOrder": "PARALLEL",
|
||||||
"allowDictateNextSigner": false,
|
"allowDictateNextSigner": false,
|
||||||
@@ -429,6 +442,7 @@ The document status changes to `COMPLETED` and `completedAt` is set.
|
|||||||
"recipients": [
|
"recipients": [
|
||||||
{
|
{
|
||||||
"id": 50,
|
"id": 50,
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"documentId": 10,
|
"documentId": 10,
|
||||||
"templateId": null,
|
"templateId": null,
|
||||||
"email": "reviewer@example.com",
|
"email": "reviewer@example.com",
|
||||||
@@ -451,6 +465,7 @@ The document status changes to `COMPLETED` and `completedAt` is set.
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 51,
|
"id": 51,
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"documentId": 10,
|
"documentId": 10,
|
||||||
"templateId": null,
|
"templateId": null,
|
||||||
"email": "signer@example.com",
|
"email": "signer@example.com",
|
||||||
@@ -475,6 +490,7 @@ The document status changes to `COMPLETED` and `completedAt` is set.
|
|||||||
"Recipient": [
|
"Recipient": [
|
||||||
{
|
{
|
||||||
"id": 50,
|
"id": 50,
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"documentId": 10,
|
"documentId": 10,
|
||||||
"templateId": null,
|
"templateId": null,
|
||||||
"email": "reviewer@example.com",
|
"email": "reviewer@example.com",
|
||||||
@@ -497,6 +513,7 @@ The document status changes to `COMPLETED` and `completedAt` is set.
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 51,
|
"id": 51,
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"documentId": 10,
|
"documentId": 10,
|
||||||
"templateId": null,
|
"templateId": null,
|
||||||
"email": "signer@example.com",
|
"email": "signer@example.com",
|
||||||
@@ -537,12 +554,14 @@ The recipient's `signingStatus` changes to `REJECTED` and `rejectionReason` cont
|
|||||||
"event": "DOCUMENT_REJECTED",
|
"event": "DOCUMENT_REJECTED",
|
||||||
"payload": {
|
"payload": {
|
||||||
"id": 10,
|
"id": 10,
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"status": "PENDING",
|
"status": "PENDING",
|
||||||
"title": "contract.pdf",
|
"title": "contract.pdf",
|
||||||
"source": "DOCUMENT",
|
"source": "DOCUMENT",
|
||||||
"recipients": [
|
"recipients": [
|
||||||
{
|
{
|
||||||
"id": 52,
|
"id": 52,
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"email": "signer@example.com",
|
"email": "signer@example.com",
|
||||||
"name": "John Doe",
|
"name": "John Doe",
|
||||||
"role": "SIGNER",
|
"role": "SIGNER",
|
||||||
@@ -561,7 +580,7 @@ The recipient's `signingStatus` changes to `REJECTED` and `rejectionReason` cont
|
|||||||
|
|
||||||
### `document.cancelled`
|
### `document.cancelled`
|
||||||
|
|
||||||
Triggered when the document owner or a team member deletes a document. Draft and pending documents are hard-deleted, while completed documents are soft-deleted.
|
Triggered when a pending document is explicitly cancelled with `POST /envelope/cancel`, or when a document owner or team member deletes a document. Deleting a draft or pending document hard-deletes it, while deleting a completed document soft-deletes it.
|
||||||
|
|
||||||
This event is **not** triggered when a recipient hides a document from their inbox.
|
This event is **not** triggered when a recipient hides a document from their inbox.
|
||||||
|
|
||||||
@@ -572,6 +591,7 @@ This event is **not** triggered when a recipient hides a document from their inb
|
|||||||
"event": "DOCUMENT_CANCELLED",
|
"event": "DOCUMENT_CANCELLED",
|
||||||
"payload": {
|
"payload": {
|
||||||
"id": 7,
|
"id": 7,
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"externalId": null,
|
"externalId": null,
|
||||||
"userId": 3,
|
"userId": 3,
|
||||||
"authOptions": null,
|
"authOptions": null,
|
||||||
@@ -591,7 +611,6 @@ This event is **not** triggered when a recipient hides a document from their inb
|
|||||||
"subject": "",
|
"subject": "",
|
||||||
"message": "",
|
"message": "",
|
||||||
"timezone": "Etc/UTC",
|
"timezone": "Etc/UTC",
|
||||||
"password": null,
|
|
||||||
"dateFormat": "yyyy-MM-dd hh:mm a",
|
"dateFormat": "yyyy-MM-dd hh:mm a",
|
||||||
"redirectUrl": "",
|
"redirectUrl": "",
|
||||||
"signingOrder": "PARALLEL",
|
"signingOrder": "PARALLEL",
|
||||||
@@ -606,6 +625,7 @@ This event is **not** triggered when a recipient hides a document from their inb
|
|||||||
"recipients": [
|
"recipients": [
|
||||||
{
|
{
|
||||||
"id": 7,
|
"id": 7,
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"documentId": 7,
|
"documentId": 7,
|
||||||
"templateId": null,
|
"templateId": null,
|
||||||
"email": "signer@example.com",
|
"email": "signer@example.com",
|
||||||
@@ -627,6 +647,7 @@ This event is **not** triggered when a recipient hides a document from their inb
|
|||||||
"Recipient": [
|
"Recipient": [
|
||||||
{
|
{
|
||||||
"id": 7,
|
"id": 7,
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"documentId": 7,
|
"documentId": 7,
|
||||||
"templateId": null,
|
"templateId": null,
|
||||||
"email": "signer@example.com",
|
"email": "signer@example.com",
|
||||||
@@ -651,6 +672,45 @@ This event is **not** triggered when a recipient hides a document from their inb
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `recipient.expired`
|
||||||
|
|
||||||
|
Triggered when a recipient's signing deadline passes on a pending document before they sign or reject it.
|
||||||
|
|
||||||
|
**Event name:** `RECIPIENT_EXPIRED`
|
||||||
|
|
||||||
|
The recipient's `expiresAt` contains the signing deadline, and `expirationNotifiedAt` is set when the expiration is processed.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "RECIPIENT_EXPIRED",
|
||||||
|
"payload": {
|
||||||
|
"id": 10,
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
|
"status": "PENDING",
|
||||||
|
"title": "contract.pdf",
|
||||||
|
"source": "DOCUMENT",
|
||||||
|
"recipients": [
|
||||||
|
{
|
||||||
|
"id": 52,
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
|
"documentId": 10,
|
||||||
|
"templateId": null,
|
||||||
|
"email": "signer@example.com",
|
||||||
|
"name": "John Doe",
|
||||||
|
"role": "SIGNER",
|
||||||
|
"expiresAt": "2024-04-22T11:51:00.000Z",
|
||||||
|
"expirationNotifiedAt": "2024-04-22T11:52:00.000Z",
|
||||||
|
"readStatus": "OPENED",
|
||||||
|
"signingStatus": "NOT_SIGNED",
|
||||||
|
"sendStatus": "SENT"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"createdAt": "2024-04-22T11:52:00.000Z",
|
||||||
|
"webhookEndpoint": "https://your-endpoint.com/webhook"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### `document.reminder.sent`
|
### `document.reminder.sent`
|
||||||
|
|
||||||
Triggered when a reminder email is sent to a recipient who has not yet completed their action.
|
Triggered when a reminder email is sent to a recipient who has not yet completed their action.
|
||||||
@@ -662,12 +722,14 @@ Triggered when a reminder email is sent to a recipient who has not yet completed
|
|||||||
"event": "DOCUMENT_REMINDER_SENT",
|
"event": "DOCUMENT_REMINDER_SENT",
|
||||||
"payload": {
|
"payload": {
|
||||||
"id": 10,
|
"id": 10,
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"status": "PENDING",
|
"status": "PENDING",
|
||||||
"title": "contract.pdf",
|
"title": "contract.pdf",
|
||||||
"source": "DOCUMENT",
|
"source": "DOCUMENT",
|
||||||
"recipients": [
|
"recipients": [
|
||||||
{
|
{
|
||||||
"id": 52,
|
"id": 52,
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"email": "signer@example.com",
|
"email": "signer@example.com",
|
||||||
"name": "John Doe",
|
"name": "John Doe",
|
||||||
"role": "SIGNER",
|
"role": "SIGNER",
|
||||||
@@ -686,7 +748,7 @@ Triggered when a reminder email is sent to a recipient who has not yet completed
|
|||||||
|
|
||||||
## Template Events
|
## Template Events
|
||||||
|
|
||||||
Template events track changes to reusable document templates. Template payloads use the same structure as document payloads, with `source` set to `TEMPLATE` and `templateId` populated.
|
Template events track changes to reusable document templates. Template payloads use the same structure as document payloads. For `TEMPLATE_CREATED`, `TEMPLATE_UPDATED`, and `TEMPLATE_DELETED` the template's own legacy numeric ID is in `id` and `templateId` is `null`. Only `TEMPLATE_USED` — whose payload describes the new document envelope created from the template — carries the originating template's legacy ID in `templateId`, with `source` set to `TEMPLATE`.
|
||||||
|
|
||||||
### `template.created`
|
### `template.created`
|
||||||
|
|
||||||
@@ -699,9 +761,10 @@ Triggered when a new template is created.
|
|||||||
"event": "TEMPLATE_CREATED",
|
"event": "TEMPLATE_CREATED",
|
||||||
"payload": {
|
"payload": {
|
||||||
"id": 10,
|
"id": 10,
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"title": "My Template",
|
"title": "My Template",
|
||||||
"status": "DRAFT",
|
"status": "DRAFT",
|
||||||
"templateId": 10,
|
"templateId": null,
|
||||||
"source": "TEMPLATE",
|
"source": "TEMPLATE",
|
||||||
"recipients": []
|
"recipients": []
|
||||||
},
|
},
|
||||||
@@ -721,9 +784,10 @@ Triggered when a template's settings, recipients, or fields are modified.
|
|||||||
"event": "TEMPLATE_UPDATED",
|
"event": "TEMPLATE_UPDATED",
|
||||||
"payload": {
|
"payload": {
|
||||||
"id": 10,
|
"id": 10,
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"title": "My Updated Template",
|
"title": "My Updated Template",
|
||||||
"status": "DRAFT",
|
"status": "DRAFT",
|
||||||
"templateId": 10,
|
"templateId": null,
|
||||||
"source": "TEMPLATE",
|
"source": "TEMPLATE",
|
||||||
"recipients": []
|
"recipients": []
|
||||||
},
|
},
|
||||||
@@ -743,9 +807,10 @@ Triggered when a template is deleted.
|
|||||||
"event": "TEMPLATE_DELETED",
|
"event": "TEMPLATE_DELETED",
|
||||||
"payload": {
|
"payload": {
|
||||||
"id": 10,
|
"id": 10,
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"title": "Deleted Template",
|
"title": "Deleted Template",
|
||||||
"status": "DRAFT",
|
"status": "DRAFT",
|
||||||
"templateId": 10,
|
"templateId": null,
|
||||||
"source": "TEMPLATE",
|
"source": "TEMPLATE",
|
||||||
"recipients": []
|
"recipients": []
|
||||||
},
|
},
|
||||||
@@ -765,6 +830,7 @@ Triggered when a document is created from a template. This event fires alongside
|
|||||||
"event": "TEMPLATE_USED",
|
"event": "TEMPLATE_USED",
|
||||||
"payload": {
|
"payload": {
|
||||||
"id": 10,
|
"id": 10,
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"title": "Document from Template",
|
"title": "Document from Template",
|
||||||
"status": "DRAFT",
|
"status": "DRAFT",
|
||||||
"templateId": 10,
|
"templateId": 10,
|
||||||
@@ -791,7 +857,8 @@ Triggered when a document is created from a template. This event fires alongside
|
|||||||
| `DOCUMENT_RECIPIENT_COMPLETED` | Recipient completes their action | Recipient `signingStatus: "SIGNED"`, `signedAt` set |
|
| `DOCUMENT_RECIPIENT_COMPLETED` | Recipient completes their action | Recipient `signingStatus: "SIGNED"`, `signedAt` set |
|
||||||
| `DOCUMENT_COMPLETED` | All recipients complete actions | `status: "COMPLETED"`, `completedAt` set |
|
| `DOCUMENT_COMPLETED` | All recipients complete actions | `status: "COMPLETED"`, `completedAt` set |
|
||||||
| `DOCUMENT_REJECTED` | Recipient rejects document | Recipient `signingStatus: "REJECTED"`, `rejectionReason` set |
|
| `DOCUMENT_REJECTED` | Recipient rejects document | Recipient `signingStatus: "REJECTED"`, `rejectionReason` set |
|
||||||
| `DOCUMENT_CANCELLED` | Owner or team member deletes document | Document cancelled or deleted |
|
| `DOCUMENT_CANCELLED` | Pending document explicitly cancelled, or document deleted | `status: "CANCELLED"` after explicit cancellation; deletion may remove or soft-delete the document |
|
||||||
|
| `RECIPIENT_EXPIRED` | Recipient signing deadline passes | Recipient `expiresAt` passed, `expirationNotifiedAt` set |
|
||||||
| `DOCUMENT_REMINDER_SENT` | Reminder email sent to recipient | No status changes |
|
| `DOCUMENT_REMINDER_SENT` | Reminder email sent to recipient | No status changes |
|
||||||
|
|
||||||
### Template Events
|
### Template Events
|
||||||
@@ -821,7 +888,7 @@ When processing webhook events:
|
|||||||
**Process idempotently** — Webhooks may be retried, so handle duplicate events
|
**Process idempotently** — Webhooks may be retried, so handle duplicate events
|
||||||
</Step>
|
</Step>
|
||||||
<Step>
|
<Step>
|
||||||
**Respond quickly** — Return a 200 status code within 30 seconds
|
**Respond quickly** — Return a `2xx` status code within 10 seconds
|
||||||
</Step>
|
</Step>
|
||||||
</Steps>
|
</Steps>
|
||||||
|
|
||||||
|
|||||||
@@ -42,12 +42,14 @@ Documenso supports webhook events for the full document lifecycle (created, sent
|
|||||||
"event": "DOCUMENT_COMPLETED",
|
"event": "DOCUMENT_COMPLETED",
|
||||||
"payload": {
|
"payload": {
|
||||||
"id": 123,
|
"id": 123,
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"title": "Contract",
|
"title": "Contract",
|
||||||
"status": "COMPLETED",
|
"status": "COMPLETED",
|
||||||
"completedAt": "2024-01-15T10:30:00.000Z",
|
"completedAt": "2024-01-15T10:30:00.000Z",
|
||||||
"recipients": [
|
"recipients": [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
|
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||||
"email": "signer@example.com",
|
"email": "signer@example.com",
|
||||||
"signingStatus": "SIGNED"
|
"signingStatus": "SIGNED"
|
||||||
}
|
}
|
||||||
@@ -58,6 +60,8 @@ Documenso supports webhook events for the full document lifecycle (created, sent
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`payload.id` is the legacy numeric v1 ID. Use `payload.envelopeId` as the canonical v2 identifier. Each recipient repeats `envelopeId` as the reliable parent link because the legacy `documentId` and `templateId` fields depend on the parent envelope type, leaving one of them null.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## See Also
|
## See Also
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ func main() {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<Callout type="warn">
|
<Callout type="warn">
|
||||||
Always respond with a `200 OK` status within 30 seconds. Documenso will retry failed deliveries.
|
Always respond with a `2xx` status within 10 seconds. Documenso will retry failed deliveries according to the configured background-job provider.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
## Configuring Webhooks in Documenso via the Dashboard
|
## Configuring Webhooks in Documenso via the Dashboard
|
||||||
@@ -184,7 +184,7 @@ Fill in the following fields:
|
|||||||
|
|
||||||
| Field | Description |
|
| Field | Description |
|
||||||
| ----- | ----------- |
|
| ----- | ----------- |
|
||||||
| **Webhook URL** | The HTTPS endpoint that will receive webhook events |
|
| **Webhook URL** | The HTTP or HTTPS endpoint that will receive webhook events |
|
||||||
| **Events** | Select which events should trigger this webhook |
|
| **Events** | Select which events should trigger this webhook |
|
||||||
| **Secret** (optional) | A secret key used to sign the payload for verification |
|
| **Secret** (optional) | A secret key used to sign the payload for verification |
|
||||||
</Step>
|
</Step>
|
||||||
@@ -202,12 +202,21 @@ Your webhook endpoint must meet these requirements:
|
|||||||
|
|
||||||
| Requirement | Details |
|
| Requirement | Details |
|
||||||
| ----------- | ------- |
|
| ----------- | ------- |
|
||||||
| **Protocol** | HTTPS required (HTTP not allowed in production) |
|
| **Protocol** | HTTP and HTTPS are accepted; use HTTPS in production |
|
||||||
| **Response** | Must return `2xx` status code within 30 seconds |
|
| **Response** | Must return a `2xx` status code within 10 seconds |
|
||||||
| **Method** | Must accept HTTP POST requests |
|
| **Method** | Must accept HTTP POST requests |
|
||||||
| **Content-Type** | Must accept `application/json` payloads |
|
| **Content-Type** | Must accept `application/json` payloads |
|
||||||
| **Availability** | Must be publicly accessible from the internet |
|
| **Availability** | Must be publicly accessible from the internet |
|
||||||
|
|
||||||
|
<Callout type="warn">
|
||||||
|
Documenso performs a best-effort check that rejects webhook URLs which use or resolve to private
|
||||||
|
or loopback addresses. This is not a complete SSRF mitigation — it does not cover DNS rebinding
|
||||||
|
and fails open on DNS lookup errors or timeouts — so self-hosted deployments should still enforce
|
||||||
|
network-level egress rules. Self-hosters that need to deliver to a hostname resolving to a
|
||||||
|
private address can add that hostname to the comma-separated
|
||||||
|
`NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS` environment variable.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
<Callout type="info">
|
<Callout type="info">
|
||||||
For local development, use a tunneling service like [ngrok](https://ngrok.com) or [localtunnel](https://localtunnel.me) to expose your local server.
|
For local development, use a tunneling service like [ngrok](https://ngrok.com) or [localtunnel](https://localtunnel.me) to expose your local server.
|
||||||
</Callout>
|
</Callout>
|
||||||
@@ -225,7 +234,8 @@ When creating a webhook, you can subscribe to one or more events:
|
|||||||
| `DOCUMENT_RECIPIENT_COMPLETED` | A recipient completes their required action |
|
| `DOCUMENT_RECIPIENT_COMPLETED` | A recipient completes their required action |
|
||||||
| `DOCUMENT_COMPLETED` | All recipients have completed their actions |
|
| `DOCUMENT_COMPLETED` | All recipients have completed their actions |
|
||||||
| `DOCUMENT_REJECTED` | A recipient rejects the document |
|
| `DOCUMENT_REJECTED` | A recipient rejects the document |
|
||||||
| `DOCUMENT_CANCELLED` | The document owner deletes the document |
|
| `DOCUMENT_CANCELLED` | A pending document is explicitly cancelled or a document owner deletes it |
|
||||||
|
| `RECIPIENT_EXPIRED` | A recipient's signing deadline passes before they sign or reject |
|
||||||
| `DOCUMENT_REMINDER_SENT` | A reminder email is sent to a recipient |
|
| `DOCUMENT_REMINDER_SENT` | A reminder email is sent to a recipient |
|
||||||
| `TEMPLATE_CREATED` | A new template is created |
|
| `TEMPLATE_CREATED` | A new template is created |
|
||||||
| `TEMPLATE_UPDATED` | A template is modified |
|
| `TEMPLATE_UPDATED` | A template is modified |
|
||||||
@@ -318,17 +328,17 @@ Documenso will attempt to deliver the same payload again
|
|||||||
|
|
||||||
## Retry Policy
|
## Retry Policy
|
||||||
|
|
||||||
When a webhook delivery fails (non-2xx response or timeout), Documenso automatically retries with exponential backoff:
|
A delivery fails when the endpoint returns a non-`2xx` response, the 10-second timeout expires, or the request fails. Redirects are not followed, so `3xx` responses also fail. Network and SSRF-blocked requests are recorded with response code `0`.
|
||||||
|
|
||||||
| Attempt | Delay |
|
For self-hosted deployments, retries are handled by the background-job provider selected with `NEXT_PRIVATE_JOBS_PROVIDER`:
|
||||||
| ------- | ----- |
|
|
||||||
| 1 | Immediate |
|
|
||||||
| 2 | 1 minute |
|
|
||||||
| 3 | 5 minutes |
|
|
||||||
| 4 | 30 minutes |
|
|
||||||
| 5 | 2 hours |
|
|
||||||
|
|
||||||
After 5 failed attempts, the webhook is marked as failed and no further automatic retries occur. You can manually resend failed webhooks from the dashboard.
|
| Provider | Total attempts | Retry timing |
|
||||||
|
| -------- | -------------- | ------------ |
|
||||||
|
| Local (default) | 4 | Back-to-back, with no backoff |
|
||||||
|
| BullMQ | 3 | Exponential backoff starting at 1 second |
|
||||||
|
| Inngest | 5 | Inngest platform backoff |
|
||||||
|
|
||||||
|
Only the individual delivery (`WebhookCall`) record is marked as failed. Documenso does not automatically disable the webhook or apply a circuit breaker, so future matching events continue to be delivered. After automatic attempts are exhausted, you can manually resend a failed delivery from the dashboard.
|
||||||
|
|
||||||
<Callout type="warn">
|
<Callout type="warn">
|
||||||
If your endpoint consistently fails, consider reviewing your server logs and ensuring your endpoint meets all [URL requirements](#webhook-url-requirements).
|
If your endpoint consistently fails, consider reviewing your server logs and ensuring your endpoint meets all [URL requirements](#webhook-url-requirements).
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ const validEvents = [
|
|||||||
'DOCUMENT_REJECTED',
|
'DOCUMENT_REJECTED',
|
||||||
'DOCUMENT_CANCELLED',
|
'DOCUMENT_CANCELLED',
|
||||||
'DOCUMENT_REMINDER_SENT',
|
'DOCUMENT_REMINDER_SENT',
|
||||||
|
'RECIPIENT_EXPIRED',
|
||||||
'TEMPLATE_CREATED',
|
'TEMPLATE_CREATED',
|
||||||
'TEMPLATE_UPDATED',
|
'TEMPLATE_UPDATED',
|
||||||
'TEMPLATE_DELETED',
|
'TEMPLATE_DELETED',
|
||||||
|
|||||||
@@ -41,12 +41,17 @@ When a limit is reached, requests return a `429 Too Many Requests` response with
|
|||||||
|
|
||||||
| Action | Limit | Window |
|
| Action | Limit | Window |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| API requests (v1 and v2) | 100 requests | 1 minute |
|
| API requests (v1 and v2) | 1000 requests | 1 minute |
|
||||||
| File uploads | 20 requests | 1 minute |
|
| File uploads | 20 requests | 1 minute |
|
||||||
| AI features | 3 requests | 1 minute |
|
| AI features | 3 requests | 1 minute |
|
||||||
|
|
||||||
Authentication endpoints (login, signup, password reset, etc.) are also rate-limited to protect against abuse.
|
Authentication endpoints (login, signup, password reset, etc.) are also rate-limited to protect against abuse.
|
||||||
|
|
||||||
|
<Callout type="info">
|
||||||
|
The API request limit above is the global per-IP ceiling. Individual organisations also have their
|
||||||
|
own rate limits, which may be configured below this value.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
<Callout type="info">
|
<Callout type="info">
|
||||||
Rate limits may vary by plan. Enterprise plans can include higher or custom limits. Contact
|
Rate limits may vary by plan. Enterprise plans can include higher or custom limits. Contact
|
||||||
[sales](https://documen.so/sales) for details.
|
[sales](https://documen.so/sales) for details.
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ There are three distinct kinds of limit:
|
|||||||
| ---------------------- | ------------------------------------------------- | ----------------------- |
|
| ---------------------- | ------------------------------------------------- | ----------------------- |
|
||||||
| Resource quota | Documents, emails, and API requests **per month** | Yes — per claim and org |
|
| Resource quota | Documents, emails, and API requests **per month** | Yes — per claim and org |
|
||||||
| Resource rate limit | The same resources over a short window (e.g. `1h`) | Yes — per claim and org |
|
| Resource rate limit | The same resources over a short window (e.g. `1h`) | Yes — per claim and org |
|
||||||
| Global HTTP rate limit | API requests per IP (100/min, hardcoded) | No — see [Limitations](#limitations) |
|
| Global HTTP rate limit | API requests per IP (1000/min, hardcoded) | No — see [Limitations](#limitations) |
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ Monthly quota usage is keyed to the **UTC calendar month**. There is no schedule
|
|||||||
|
|
||||||
## Limitations
|
## Limitations
|
||||||
|
|
||||||
The **global HTTP rate limit is not configurable.** Documenso enforces a hardcoded **100 requests per minute per IP address** on its API endpoint groups (`/api/v1`, `/api/v2`, and the tRPC API are limited separately), returning `429 Too Many Requests`. It is a per-IP safeguard applied at the HTTP layer — not per-organisation, not stored on any claim, and not adjustable from the admin panel. See [Rate Limits](/docs/developers/api/rate-limits).
|
The **global HTTP rate limit is not configurable.** Documenso enforces a hardcoded **1000 requests per minute per IP address** on its API endpoint groups (`/api/v1`, `/api/v2`, and the tRPC API are limited separately), returning `429 Too Many Requests`. It is a per-IP safeguard applied at the HTTP layer — not per-organisation, not stored on any claim, and not adjustable from the admin panel. See [Rate Limits](/docs/developers/api/rate-limits).
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "NEXT_IGNORE_INCORRECT_LOCKFILE=true next build",
|
"build": "next build",
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"types:check": "fumadocs-mdx && next typegen && tsc --noEmit",
|
"types:check": "fumadocs-mdx && next typegen && tsc --noEmit",
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Callout } from 'fumadocs-ui/components/callout';
|
||||||
|
|
||||||
|
const MIGRATION_GUIDE_HREF = '/docs/developers/api/migrate-to-envelopes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deprecation banner steering API consumers away from the legacy document and
|
||||||
|
* template create endpoints and towards the unified Envelope API.
|
||||||
|
*
|
||||||
|
* Registered globally in `mdx-components.tsx`, so it can be used in any MDX page
|
||||||
|
* as `<EnvelopeWarning />` without an explicit import.
|
||||||
|
*/
|
||||||
|
export function EnvelopeWarning() {
|
||||||
|
return (
|
||||||
|
<Callout type="error">
|
||||||
|
<strong>Documents and templates are being deprecated and replaced by envelopes.</strong>{' '}
|
||||||
|
<a href={MIGRATION_GUIDE_HREF}>Read the migration guide here.</a>
|
||||||
|
</Callout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as TabsComponents from 'fumadocs-ui/components/tabs';
|
import * as TabsComponents from 'fumadocs-ui/components/tabs';
|
||||||
import defaultMdxComponents from 'fumadocs-ui/mdx';
|
import defaultMdxComponents from 'fumadocs-ui/mdx';
|
||||||
import type { MDXComponents } from 'mdx/types';
|
import type { MDXComponents } from 'mdx/types';
|
||||||
|
import { EnvelopeWarning } from '@/components/mdx/envelope-warning';
|
||||||
import { Mermaid } from '@/components/mdx/mermaid';
|
import { Mermaid } from '@/components/mdx/mermaid';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -9,6 +10,7 @@ export function getMDXComponents(components?: MDXComponents): any {
|
|||||||
...defaultMdxComponents,
|
...defaultMdxComponents,
|
||||||
...TabsComponents,
|
...TabsComponents,
|
||||||
Mermaid,
|
Mermaid,
|
||||||
|
EnvelopeWarning,
|
||||||
...components,
|
...components,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export type BrandingPreferencesResetDialogProps = {
|
||||||
|
hasAdvancedBranding: boolean;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
onReset: () => Promise<void>;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BrandingPreferencesResetDialog = ({
|
||||||
|
hasAdvancedBranding,
|
||||||
|
isSubmitting,
|
||||||
|
onReset,
|
||||||
|
trigger,
|
||||||
|
}: BrandingPreferencesResetDialogProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [isResetting, setIsResetting] = useState(false);
|
||||||
|
|
||||||
|
const isLoading = isSubmitting || isResetting;
|
||||||
|
|
||||||
|
const handleResetToDefaults = async () => {
|
||||||
|
setIsResetting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onReset();
|
||||||
|
setOpen(false);
|
||||||
|
} catch {
|
||||||
|
// The submit handler surfaces its own error toast. Keep the dialog open
|
||||||
|
// so the user can retry.
|
||||||
|
} finally {
|
||||||
|
setIsResetting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(value) => !isLoading && setOpen(value)}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{trigger ?? (
|
||||||
|
<Button variant="destructive" type="button" size="sm" disabled={isLoading}>
|
||||||
|
<Trans>Reset to defaults</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Reset branding preferences</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>
|
||||||
|
This will reset all branding preferences to their default values and save the changes immediately.
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertDescription>
|
||||||
|
<p>
|
||||||
|
<Trans>Once confirmed, the following will be reset:</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul className="mt-0.5 list-inside list-disc">
|
||||||
|
<li>
|
||||||
|
<Trans>Custom branding enabled setting</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Branding logo</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Brand website and brand details</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Brand colours, including background, foreground, primary, and border colours</Trans>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{hasAdvancedBranding && (
|
||||||
|
<>
|
||||||
|
<li>
|
||||||
|
<Trans>Border radius</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Custom CSS</Trans>
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="secondary" disabled={isLoading}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
|
<Button type="button" variant="destructive" loading={isLoading} onClick={() => void handleResetToDefaults()}>
|
||||||
|
<Trans>Reset to defaults</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export type DocumentPreferencesResetDialogProps = {
|
||||||
|
isSubmitting: boolean;
|
||||||
|
onReset: () => Promise<void>;
|
||||||
|
showAiFeatures?: boolean;
|
||||||
|
showDocumentVisibility?: boolean;
|
||||||
|
showIncludeSenderDetails?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DocumentPreferencesResetDialog = ({
|
||||||
|
isSubmitting,
|
||||||
|
onReset,
|
||||||
|
showAiFeatures = false,
|
||||||
|
showDocumentVisibility = false,
|
||||||
|
showIncludeSenderDetails = false,
|
||||||
|
}: DocumentPreferencesResetDialogProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [isResetting, setIsResetting] = useState(false);
|
||||||
|
|
||||||
|
const isLoading = isSubmitting || isResetting;
|
||||||
|
|
||||||
|
const handleResetToDefaults = async () => {
|
||||||
|
setIsResetting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onReset();
|
||||||
|
setOpen(false);
|
||||||
|
} catch {
|
||||||
|
// The submit handler surfaces its own error toast. Keep the dialog open
|
||||||
|
// so the user can retry.
|
||||||
|
} finally {
|
||||||
|
setIsResetting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(value) => !isLoading && setOpen(value)}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="destructive" type="button" size="sm" disabled={isLoading}>
|
||||||
|
<Trans>Reset to defaults</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Reset document preferences</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>
|
||||||
|
This will reset all document preferences to their default values and save the changes immediately.
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertDescription>
|
||||||
|
<p>
|
||||||
|
<Trans>Once confirmed, the following will be reset:</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul className="mt-0.5 list-inside list-disc">
|
||||||
|
{showDocumentVisibility && (
|
||||||
|
<li>
|
||||||
|
<Trans>Default document visibility</Trans>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
<li>
|
||||||
|
<Trans>Default document language</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Default date format</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Default time zone</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Default signature settings</Trans>
|
||||||
|
</li>
|
||||||
|
{showIncludeSenderDetails && (
|
||||||
|
<li>
|
||||||
|
<Trans>Send on behalf of team</Trans>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
<li>
|
||||||
|
<Trans>Include the signing certificate in the document</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Include the audit logs in the document</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Default recipients</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Delegate document ownership</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Default envelope expiration</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Default signing reminders</Trans>
|
||||||
|
</li>
|
||||||
|
{showAiFeatures && (
|
||||||
|
<li>
|
||||||
|
<Trans>AI features</Trans>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="secondary" disabled={isLoading}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
|
<Button type="button" variant="destructive" loading={isLoading} onClick={() => void handleResetToDefaults()}>
|
||||||
|
<Trans>Reset to defaults</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from '@documenso/lib/constants/branding';
|
} from '@documenso/lib/constants/branding';
|
||||||
import { DEFAULT_BRAND_COLORS, DEFAULT_BRAND_RADIUS } from '@documenso/lib/constants/theme';
|
import { DEFAULT_BRAND_COLORS, DEFAULT_BRAND_RADIUS } from '@documenso/lib/constants/theme';
|
||||||
import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
|
import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
|
||||||
|
import { normalizeBrandingColors } from '@documenso/lib/utils/normalize-branding-colors';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion';
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -23,6 +24,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { BrandingPreferencesResetDialog } from '~/components/dialogs/branding-preferences-reset-dialog';
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
import { useCspNonce } from '~/utils/nonce';
|
import { useCspNonce } from '~/utils/nonce';
|
||||||
|
|
||||||
@@ -74,6 +76,7 @@ export function BrandingPreferencesForm({
|
|||||||
|
|
||||||
const [previewUrl, setPreviewUrl] = useState<string>('');
|
const [previewUrl, setPreviewUrl] = useState<string>('');
|
||||||
const [hasLoadedPreview, setHasLoadedPreview] = useState(false);
|
const [hasLoadedPreview, setHasLoadedPreview] = useState(false);
|
||||||
|
const [colorPickerKey, setColorPickerKey] = useState(0);
|
||||||
|
|
||||||
const parsedColors = ZCssVarsSchema.safeParse(settings.brandingColors);
|
const parsedColors = ZCssVarsSchema.safeParse(settings.brandingColors);
|
||||||
const initialColors = parsedColors.success ? parsedColors.data : {};
|
const initialColors = parsedColors.success ? parsedColors.data : {};
|
||||||
@@ -96,6 +99,42 @@ export function BrandingPreferencesForm({
|
|||||||
|
|
||||||
const isBrandingEnabled = form.watch('brandingEnabled');
|
const isBrandingEnabled = form.watch('brandingEnabled');
|
||||||
|
|
||||||
|
const hasResetBrandingColors =
|
||||||
|
settings.brandingColors === null ||
|
||||||
|
settings.brandingColors === undefined ||
|
||||||
|
(parsedColors.success && normalizeBrandingColors(parsedColors.data) === null);
|
||||||
|
|
||||||
|
// Only show the reset action when the saved settings actually differ from the
|
||||||
|
// defaults, so it never renders as a pointless disabled button.
|
||||||
|
const isResetToDefaultsVisible =
|
||||||
|
settings.brandingEnabled !== (canInherit ? null : false) ||
|
||||||
|
!!settings.brandingLogo ||
|
||||||
|
!!settings.brandingUrl ||
|
||||||
|
!!settings.brandingCompanyDetails ||
|
||||||
|
!!settings.brandingCss ||
|
||||||
|
!hasResetBrandingColors;
|
||||||
|
|
||||||
|
const handleResetToDefaults = async () => {
|
||||||
|
const data: TBrandingPreferencesFormSchema = {
|
||||||
|
brandingEnabled: canInherit ? null : false,
|
||||||
|
brandingLogo: null,
|
||||||
|
brandingUrl: '',
|
||||||
|
brandingCompanyDetails: '',
|
||||||
|
brandingColors: {},
|
||||||
|
brandingCss: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
await onFormSubmit(data);
|
||||||
|
|
||||||
|
if (previewUrl.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreviewUrl('');
|
||||||
|
setColorPickerKey((key) => key + 1);
|
||||||
|
form.reset(data);
|
||||||
|
};
|
||||||
|
|
||||||
const getSavedLogoPreviewUrl = () => {
|
const getSavedLogoPreviewUrl = () => {
|
||||||
if (!settings.brandingLogo) {
|
if (!settings.brandingLogo) {
|
||||||
return '';
|
return '';
|
||||||
@@ -397,6 +436,7 @@ export function BrandingPreferencesForm({
|
|||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
|
key={`background-${colorPickerKey}`}
|
||||||
nonce={nonce}
|
nonce={nonce}
|
||||||
value={field.value ?? ''}
|
value={field.value ?? ''}
|
||||||
defaultValue={DEFAULT_BRAND_COLORS.background}
|
defaultValue={DEFAULT_BRAND_COLORS.background}
|
||||||
@@ -420,6 +460,7 @@ export function BrandingPreferencesForm({
|
|||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
|
key={`foreground-${colorPickerKey}`}
|
||||||
nonce={nonce}
|
nonce={nonce}
|
||||||
value={field.value ?? ''}
|
value={field.value ?? ''}
|
||||||
defaultValue={DEFAULT_BRAND_COLORS.foreground}
|
defaultValue={DEFAULT_BRAND_COLORS.foreground}
|
||||||
@@ -443,6 +484,7 @@ export function BrandingPreferencesForm({
|
|||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
|
key={`primary-${colorPickerKey}`}
|
||||||
nonce={nonce}
|
nonce={nonce}
|
||||||
value={field.value ?? ''}
|
value={field.value ?? ''}
|
||||||
defaultValue={DEFAULT_BRAND_COLORS.primary}
|
defaultValue={DEFAULT_BRAND_COLORS.primary}
|
||||||
@@ -466,6 +508,7 @@ export function BrandingPreferencesForm({
|
|||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
|
key={`primary-foreground-${colorPickerKey}`}
|
||||||
nonce={nonce}
|
nonce={nonce}
|
||||||
value={field.value ?? ''}
|
value={field.value ?? ''}
|
||||||
defaultValue={DEFAULT_BRAND_COLORS.primaryForeground}
|
defaultValue={DEFAULT_BRAND_COLORS.primaryForeground}
|
||||||
@@ -489,6 +532,7 @@ export function BrandingPreferencesForm({
|
|||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
|
key={`border-${colorPickerKey}`}
|
||||||
nonce={nonce}
|
nonce={nonce}
|
||||||
value={field.value ?? ''}
|
value={field.value ?? ''}
|
||||||
defaultValue={DEFAULT_BRAND_COLORS.border}
|
defaultValue={DEFAULT_BRAND_COLORS.border}
|
||||||
@@ -512,6 +556,7 @@ export function BrandingPreferencesForm({
|
|||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
|
key={`ring-${colorPickerKey}`}
|
||||||
nonce={nonce}
|
nonce={nonce}
|
||||||
value={field.value ?? ''}
|
value={field.value ?? ''}
|
||||||
defaultValue={DEFAULT_BRAND_COLORS.ring}
|
defaultValue={DEFAULT_BRAND_COLORS.ring}
|
||||||
@@ -593,6 +638,15 @@ export function BrandingPreferencesForm({
|
|||||||
isDirty={hasUnsavedChanges}
|
isDirty={hasUnsavedChanges}
|
||||||
isSubmitting={form.formState.isSubmitting}
|
isSubmitting={form.formState.isSubmitting}
|
||||||
onReset={handleReset}
|
onReset={handleReset}
|
||||||
|
resetToDefaults={
|
||||||
|
isResetToDefaultsVisible ? (
|
||||||
|
<BrandingPreferencesResetDialog
|
||||||
|
hasAdvancedBranding={hasAdvancedBranding}
|
||||||
|
isSubmitting={form.formState.isSubmitting}
|
||||||
|
onReset={handleResetToDefaults}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import { isValidLanguageCode, SUPPORTED_LANGUAGE_CODES, SUPPORTED_LANGUAGES } fr
|
|||||||
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||||
import type { TDefaultRecipients } from '@documenso/lib/types/default-recipients';
|
import type { TDefaultRecipients } from '@documenso/lib/types/default-recipients';
|
||||||
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
|
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
|
||||||
import { type TDocumentMetaDateFormat, ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta';
|
import { type TDocumentMetaDateFormat, ZDocumentMetaDateFormatSchema } from '@documenso/lib/types/document-meta';
|
||||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
import { generateDefaultOrganisationSettings, isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
|
import { extractTeamSignatureSettings, generateDefaultTeamSettings } from '@documenso/lib/utils/teams';
|
||||||
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
|
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
|
||||||
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
|
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
|
||||||
import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
|
import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
|
||||||
@@ -37,11 +37,11 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||||||
import { msg, t } from '@lingui/core/macro';
|
import { msg, t } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { TeamGlobalSettings } from '@prisma/client';
|
import { DocumentVisibility, OrganisationType, type RecipientRole, type TeamGlobalSettings } from '@prisma/client';
|
||||||
import { DocumentVisibility, OrganisationType, type RecipientRole } from '@prisma/client';
|
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { DocumentPreferencesResetDialog } from '~/components/dialogs/document-preferences-reset-dialog';
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox';
|
import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox';
|
||||||
@@ -93,6 +93,26 @@ export type DocumentPreferencesFormProps = {
|
|||||||
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
|
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDocumentPreferencesFormValues = (settings: SettingsSubset): TDocumentPreferencesFormSchema => {
|
||||||
|
const parsedDocumentDateFormat = ZDocumentMetaDateFormatSchema.safeParse(settings.documentDateFormat);
|
||||||
|
|
||||||
|
return {
|
||||||
|
documentVisibility: settings.documentVisibility,
|
||||||
|
documentLanguage: isValidLanguageCode(settings.documentLanguage) ? settings.documentLanguage : null,
|
||||||
|
documentTimezone: settings.documentTimezone,
|
||||||
|
documentDateFormat: parsedDocumentDateFormat.success ? parsedDocumentDateFormat.data : null,
|
||||||
|
includeSenderDetails: settings.includeSenderDetails,
|
||||||
|
includeSigningCertificate: settings.includeSigningCertificate,
|
||||||
|
includeAuditLog: settings.includeAuditLog,
|
||||||
|
signatureTypes: extractTeamSignatureSettings({ ...settings }),
|
||||||
|
defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null,
|
||||||
|
delegateDocumentOwnership: settings.delegateDocumentOwnership,
|
||||||
|
aiFeaturesEnabled: settings.aiFeaturesEnabled,
|
||||||
|
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
|
||||||
|
reminderSettings: settings.reminderSettings ?? null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const DocumentPreferencesForm = ({
|
export const DocumentPreferencesForm = ({
|
||||||
settings,
|
settings,
|
||||||
onFormSubmit,
|
onFormSubmit,
|
||||||
@@ -113,7 +133,7 @@ export const DocumentPreferencesForm = ({
|
|||||||
documentVisibility: z.nativeEnum(DocumentVisibility).nullable(),
|
documentVisibility: z.nativeEnum(DocumentVisibility).nullable(),
|
||||||
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).nullable(),
|
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).nullable(),
|
||||||
documentTimezone: z.string().nullable(),
|
documentTimezone: z.string().nullable(),
|
||||||
documentDateFormat: ZDocumentMetaTimezoneSchema.nullable(),
|
documentDateFormat: ZDocumentMetaDateFormatSchema.nullable(),
|
||||||
includeSenderDetails: z.boolean().nullable(),
|
includeSenderDetails: z.boolean().nullable(),
|
||||||
includeSigningCertificate: z.boolean().nullable(),
|
includeSigningCertificate: z.boolean().nullable(),
|
||||||
includeAuditLog: z.boolean().nullable(),
|
includeAuditLog: z.boolean().nullable(),
|
||||||
@@ -127,26 +147,33 @@ export const DocumentPreferencesForm = ({
|
|||||||
reminderSettings: ZEnvelopeReminderSettings.nullable(),
|
reminderSettings: ZEnvelopeReminderSettings.nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const defaultValues = getDocumentPreferencesFormValues(settings);
|
||||||
|
const defaultSettings = canInherit ? generateDefaultTeamSettings() : generateDefaultOrganisationSettings();
|
||||||
|
const baseResetValues = getDocumentPreferencesFormValues(defaultSettings);
|
||||||
|
const resetValues = {
|
||||||
|
...baseResetValues,
|
||||||
|
aiFeaturesEnabled: isAiFeaturesConfigured ? baseResetValues.aiFeaturesEnabled : defaultValues.aiFeaturesEnabled,
|
||||||
|
};
|
||||||
|
|
||||||
const form = useForm<TDocumentPreferencesFormSchema>({
|
const form = useForm<TDocumentPreferencesFormSchema>({
|
||||||
defaultValues: {
|
defaultValues,
|
||||||
documentVisibility: settings.documentVisibility,
|
|
||||||
documentLanguage: isValidLanguageCode(settings.documentLanguage) ? settings.documentLanguage : null,
|
|
||||||
documentTimezone: settings.documentTimezone,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
documentDateFormat: settings.documentDateFormat as TDocumentMetaDateFormat | null,
|
|
||||||
includeSenderDetails: settings.includeSenderDetails,
|
|
||||||
includeSigningCertificate: settings.includeSigningCertificate,
|
|
||||||
includeAuditLog: settings.includeAuditLog,
|
|
||||||
signatureTypes: extractTeamSignatureSettings({ ...settings }),
|
|
||||||
defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null,
|
|
||||||
delegateDocumentOwnership: settings.delegateDocumentOwnership,
|
|
||||||
aiFeaturesEnabled: settings.aiFeaturesEnabled,
|
|
||||||
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
|
|
||||||
reminderSettings: settings.reminderSettings ?? null,
|
|
||||||
},
|
|
||||||
resolver: zodResolver(ZDocumentPreferencesFormSchema),
|
resolver: zodResolver(ZDocumentPreferencesFormSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Parse both sides through the schema so we compare canonical representations
|
||||||
|
const parsedCurrentValues = ZDocumentPreferencesFormSchema.safeParse(defaultValues);
|
||||||
|
const parsedResetValues = ZDocumentPreferencesFormSchema.safeParse(resetValues);
|
||||||
|
|
||||||
|
const isResetToDefaultsVisible =
|
||||||
|
!parsedCurrentValues.success ||
|
||||||
|
!parsedResetValues.success ||
|
||||||
|
JSON.stringify(parsedCurrentValues.data) !== JSON.stringify(parsedResetValues.data);
|
||||||
|
|
||||||
|
const handleResetToDefaults = async () => {
|
||||||
|
await onFormSubmit(resetValues);
|
||||||
|
form.reset(resetValues);
|
||||||
|
};
|
||||||
|
|
||||||
const handleFormSubmit = form.handleSubmit(async (data) => {
|
const handleFormSubmit = form.handleSubmit(async (data) => {
|
||||||
try {
|
try {
|
||||||
await onFormSubmit(data);
|
await onFormSubmit(data);
|
||||||
@@ -772,6 +799,17 @@ export const DocumentPreferencesForm = ({
|
|||||||
isDirty={form.formState.isDirty}
|
isDirty={form.formState.isDirty}
|
||||||
isSubmitting={form.formState.isSubmitting}
|
isSubmitting={form.formState.isSubmitting}
|
||||||
onReset={() => form.reset()}
|
onReset={() => form.reset()}
|
||||||
|
resetToDefaults={
|
||||||
|
isResetToDefaultsVisible ? (
|
||||||
|
<DocumentPreferencesResetDialog
|
||||||
|
isSubmitting={form.formState.isSubmitting}
|
||||||
|
onReset={handleResetToDefaults}
|
||||||
|
showAiFeatures={isAiFeaturesConfigured}
|
||||||
|
showDocumentVisibility={!isPersonalLayoutMode}
|
||||||
|
showIncludeSenderDetails={!isPersonalLayoutMode && !isPersonalOrganisation}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -3,12 +3,17 @@ import { Button } from '@documenso/ui/primitives/button';
|
|||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { AlertTriangleIcon } from 'lucide-react';
|
import { AlertTriangleIcon } from 'lucide-react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
export type FormStickySaveBarProps = {
|
export type FormStickySaveBarProps = {
|
||||||
isDirty: boolean;
|
isDirty: boolean;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
|
/**
|
||||||
|
* Slot for a "reset to defaults" action, rendered before the Undo button. Hidden while
|
||||||
|
* the bar is floating so it never appears in the unsaved-changes island.
|
||||||
|
*/
|
||||||
|
resetToDefaults?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,7 +29,7 @@ export type FormStickySaveBarProps = {
|
|||||||
* shared-layout morph). A 1px sentinel below it detects the stuck state so we can toggle
|
* shared-layout morph). A 1px sentinel below it detects the stuck state so we can toggle
|
||||||
* the pill chrome.
|
* the pill chrome.
|
||||||
*/
|
*/
|
||||||
export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset }: FormStickySaveBarProps) => {
|
export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset, resetToDefaults }: FormStickySaveBarProps) => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -100,6 +105,8 @@ export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset }: FormSticky
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
<div className="ml-auto flex flex-shrink-0 items-center gap-x-2">
|
<div className="ml-auto flex flex-shrink-0 items-center gap-x-2">
|
||||||
|
{!isFloating && resetToDefaults}
|
||||||
|
|
||||||
{isDirty && (
|
{isDirty && (
|
||||||
<Button type="button" variant="secondary" size="sm" onClick={onReset} disabled={isSubmitting}>
|
<Button type="button" variant="secondary" size="sm" onClick={onReset} disabled={isSubmitting}>
|
||||||
<Trans>Undo</Trans>
|
<Trans>Undo</Trans>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||||
import type { TCachedLicense } from '@documenso/lib/types/license';
|
import type { TCachedLicense } from '@documenso/lib/types/license';
|
||||||
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
|
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@@ -9,6 +10,7 @@ import { Trans, useLingui } from '@lingui/react/macro';
|
|||||||
import {
|
import {
|
||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
CheckCircle2Icon,
|
CheckCircle2Icon,
|
||||||
|
CopyIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
EyeOffIcon,
|
EyeOffIcon,
|
||||||
KeyRoundIcon,
|
KeyRoundIcon,
|
||||||
@@ -29,6 +31,8 @@ type AdminLicenseCardProps = {
|
|||||||
|
|
||||||
export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
|
export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
|
||||||
const { t, i18n } = useLingui();
|
const { t, i18n } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [, copy] = useCopyToClipboard();
|
||||||
const [isLicenseKeyVisible, setIsLicenseKeyVisible] = useState(false);
|
const [isLicenseKeyVisible, setIsLicenseKeyVisible] = useState(false);
|
||||||
|
|
||||||
const { license } = licenseData || {};
|
const { license } = licenseData || {};
|
||||||
@@ -147,6 +151,24 @@ export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
|
|||||||
>
|
>
|
||||||
{isLicenseKeyVisible ? <EyeOffIcon className="h-3.5 w-3.5" /> : <EyeIcon className="h-3.5 w-3.5" />}
|
{isLicenseKeyVisible ? <EyeOffIcon className="h-3.5 w-3.5" /> : <EyeIcon className="h-3.5 w-3.5" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 text-muted-foreground"
|
||||||
|
aria-label={t`Copy license key`}
|
||||||
|
onClick={async () =>
|
||||||
|
copy(license.licenseKey).then(() => {
|
||||||
|
toast({
|
||||||
|
title: t`Copied to clipboard`,
|
||||||
|
description: t`The license key has been copied to your clipboard`,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CopyIcon className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
|||||||
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
export type PromptItem = {
|
||||||
|
id: string;
|
||||||
|
label: string | MessageDescriptor;
|
||||||
|
sublabel?: string;
|
||||||
|
path?: string;
|
||||||
|
onAction?: () => void;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
initials?: string;
|
||||||
|
shortcut?: string;
|
||||||
|
isChecked?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PromptCategory = {
|
||||||
|
id: string;
|
||||||
|
label: MessageDescriptor;
|
||||||
|
items: PromptItem[];
|
||||||
|
/**
|
||||||
|
* The number of actual results, excluding utility rows such as the
|
||||||
|
* "View all results" link.
|
||||||
|
*/
|
||||||
|
count: number;
|
||||||
|
/**
|
||||||
|
* The count shown on the category chip, or null to not show a chip at all.
|
||||||
|
* Categories which only contain hardcoded page links have no chip.
|
||||||
|
*/
|
||||||
|
chipCount: number | null;
|
||||||
|
isCapped: boolean;
|
||||||
|
/**
|
||||||
|
* Global admin categories are marked with a globe icon to distinguish them
|
||||||
|
* from the equally named personal categories.
|
||||||
|
*/
|
||||||
|
isGlobal: boolean;
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { AlertTriangleIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
export const DirectTemplateInvalidPageView = () => {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex h-[70vh] w-full max-w-md flex-col items-center justify-center">
|
||||||
|
<div>
|
||||||
|
<AlertTriangleIcon className="h-10 w-10 text-destructive" />
|
||||||
|
|
||||||
|
<h1 className="mt-4 font-semibold text-3xl">
|
||||||
|
<Trans>Invalid direct link template</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-2 text-muted-foreground text-sm">
|
||||||
|
<Trans>
|
||||||
|
This direct link template cannot be used because one or more signers do not have a signature field assigned.
|
||||||
|
Please contact the sender to update the template.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -4,7 +4,7 @@ import { cn } from '@documenso/ui/lib/utils';
|
|||||||
import type { MessageDescriptor } from '@lingui/core';
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { CheckCircle2, Clock, File, XCircle } from 'lucide-react';
|
import { CheckCircle2, Clock, File, TimerOff, XCircle } from 'lucide-react';
|
||||||
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||||
import type { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
@@ -46,6 +46,12 @@ export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus>
|
|||||||
icon: XCircle,
|
icon: XCircle,
|
||||||
color: 'text-red-500 dark:text-red-300',
|
color: 'text-red-500 dark:text-red-300',
|
||||||
},
|
},
|
||||||
|
EXPIRED: {
|
||||||
|
label: msg`Expired`,
|
||||||
|
labelExtended: msg`Document expired`,
|
||||||
|
icon: TimerOff,
|
||||||
|
color: 'text-orange-500 dark:text-orange-300',
|
||||||
|
},
|
||||||
INBOX: {
|
INBOX: {
|
||||||
label: msg`Inbox`,
|
label: msg`Inbox`,
|
||||||
labelExtended: msg`Document inbox`,
|
labelExtended: msg`Document inbox`,
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import { useCurrentTeam } from '~/providers/team';
|
|||||||
|
|
||||||
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
|
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
|
||||||
import { EnvelopeEditorFieldsPageRenderer } from './envelope-editor-fields-page-renderer';
|
import { EnvelopeEditorFieldsPageRenderer } from './envelope-editor-fields-page-renderer';
|
||||||
|
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
|
||||||
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
||||||
import { EnvelopeRecipientSelector } from './envelope-recipient-selector';
|
import { EnvelopeRecipientSelector } from './envelope-recipient-selector';
|
||||||
|
|
||||||
@@ -238,6 +239,8 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<EnvelopeEditorInvalidDirectTemplateAlert />
|
||||||
|
|
||||||
{/* Document View */}
|
{/* Document View */}
|
||||||
<div className="mt-4 flex h-full flex-col items-center justify-center">
|
<div className="mt-4 flex h-full flex-col items-center justify-center">
|
||||||
{envelope.recipients.length === 0 && (
|
{envelope.recipients.length === 0 && (
|
||||||
|
|||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
|
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
export type EnvelopeEditorInvalidDirectTemplateAlertProps = {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warns that a direct link template cannot be used because one or more signers
|
||||||
|
* are missing a signature field.
|
||||||
|
*/
|
||||||
|
export const EnvelopeEditorInvalidDirectTemplateAlert = ({
|
||||||
|
className,
|
||||||
|
}: EnvelopeEditorInvalidDirectTemplateAlertProps) => {
|
||||||
|
const { envelope, isTemplate } = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
|
const signersMissingSignatureFields = useMemo(() => {
|
||||||
|
if (!isTemplate || !envelope.directLink?.enabled) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return getRecipientsWithMissingFields(envelope.recipients, envelope.fields);
|
||||||
|
}, [isTemplate, envelope.directLink, envelope.recipients, envelope.fields]);
|
||||||
|
|
||||||
|
if (signersMissingSignatureFields.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
variant="destructive"
|
||||||
|
className={cn('mx-auto w-full max-w-[800px] flex-row items-start gap-3 rounded-sm', className)}
|
||||||
|
>
|
||||||
|
<AlertTitle>
|
||||||
|
<Trans>Invalid direct link template</Trans>
|
||||||
|
</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription>
|
||||||
|
<Trans>
|
||||||
|
Recipients cannot use this direct link template because the following signers are missing a signature field
|
||||||
|
</Trans>
|
||||||
|
|
||||||
|
<ul className="list-disc pl-5">
|
||||||
|
{signersMissingSignatureFields.map((recipient, i) => (
|
||||||
|
<li key={recipient.id}>{recipient.email || recipient.name || `Recipient ${i + 1}`}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -22,6 +22,7 @@ import { match } from 'ts-pattern';
|
|||||||
import { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer';
|
import { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer';
|
||||||
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
|
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
|
||||||
|
|
||||||
|
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
|
||||||
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
||||||
|
|
||||||
export const EnvelopeEditorPreviewPage = () => {
|
export const EnvelopeEditorPreviewPage = () => {
|
||||||
@@ -228,6 +229,8 @@ export const EnvelopeEditorPreviewPage = () => {
|
|||||||
{/* Horizontal envelope item selector */}
|
{/* Horizontal envelope item selector */}
|
||||||
<EnvelopeRendererFileSelector className="px-0" fields={editorFields.localFields} />
|
<EnvelopeRendererFileSelector className="px-0" fields={editorFields.localFields} />
|
||||||
|
|
||||||
|
<EnvelopeEditorInvalidDirectTemplateAlert className="mb-4" />
|
||||||
|
|
||||||
<Alert variant="warning" className="mx-auto max-w-[800px]">
|
<Alert variant="warning" className="mx-auto max-w-[800px]">
|
||||||
<AlertTitle>
|
<AlertTitle>
|
||||||
<Trans>Preview Mode</Trans>
|
<Trans>Preview Mode</Trans>
|
||||||
|
|||||||
+71
-44
@@ -7,7 +7,12 @@ import { useOptionalSession } from '@documenso/lib/client-only/providers/session
|
|||||||
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
|
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
|
||||||
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
|
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
|
||||||
import { nanoid } from '@documenso/lib/universal/id';
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
|
import {
|
||||||
|
isAssistantLastSigner,
|
||||||
|
isCcRecipient,
|
||||||
|
normalizeRecipientSigningOrders,
|
||||||
|
canRecipientBeModified as utilCanRecipientBeModified,
|
||||||
|
} from '@documenso/lib/utils/recipients';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
|
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
|
||||||
import {
|
import {
|
||||||
@@ -156,16 +161,12 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
}, [watchedSigners]);
|
}, [watchedSigners]);
|
||||||
|
|
||||||
const normalizeSigningOrders = (signers: typeof watchedSigners) => {
|
const normalizeSigningOrders = (signers: typeof watchedSigners) => {
|
||||||
return signers
|
return normalizeRecipientSigningOrders(signers, (signer) => canRecipientBeModified(signer.id));
|
||||||
.sort((a, b) => (a.signingOrder ?? 0) - (b.signingOrder ?? 0))
|
|
||||||
.map((signer, index) => ({ ...signer, signingOrder: index + 1 }));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const {
|
const activeRecipientCount = watchedSigners.filter((signer) => !isCcRecipient(signer)).length;
|
||||||
append: appendSigner,
|
|
||||||
fields: signers,
|
const { fields: signers, remove: removeSigner } = useFieldArray({
|
||||||
remove: removeSigner,
|
|
||||||
} = useFieldArray({
|
|
||||||
control,
|
control,
|
||||||
name: 'signers',
|
name: 'signers',
|
||||||
keyName: 'nativeId',
|
keyName: 'nativeId',
|
||||||
@@ -208,14 +209,31 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
return utilCanRecipientBeModified(recipient, fields);
|
return utilCanRecipientBeModified(recipient, fields);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const appendNormalizedSigner = (signer: (typeof watchedSigners)[number], shouldFocus = false) => {
|
||||||
|
const updatedSigners = normalizeSigningOrders([...form.getValues('signers'), signer]);
|
||||||
|
|
||||||
|
form.setValue('signers', updatedSigners, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shouldFocus) {
|
||||||
|
const signerIndex = updatedSigners.findIndex((updatedSigner) => updatedSigner.formId === signer.formId);
|
||||||
|
|
||||||
|
if (signerIndex !== -1) {
|
||||||
|
requestAnimationFrame(() => form.setFocus(`signers.${signerIndex}.email`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onAddSigner = () => {
|
const onAddSigner = () => {
|
||||||
appendSigner({
|
appendNormalizedSigner({
|
||||||
formId: nanoid(12),
|
formId: nanoid(12),
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
role: RecipientRole.SIGNER,
|
role: RecipientRole.SIGNER,
|
||||||
actionAuth: [],
|
actionAuth: [],
|
||||||
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
|
signingOrder: activeRecipientCount + 1,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -323,18 +341,16 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
|
|
||||||
form.setFocus(`signers.${emptySignerIndex}.email`);
|
form.setFocus(`signers.${emptySignerIndex}.email`);
|
||||||
} else {
|
} else {
|
||||||
appendSigner(
|
appendNormalizedSigner(
|
||||||
{
|
{
|
||||||
formId: nanoid(12),
|
formId: nanoid(12),
|
||||||
name: currentEditorName ?? '',
|
name: currentEditorName ?? '',
|
||||||
email: currentEditorEmail ?? '',
|
email: currentEditorEmail ?? '',
|
||||||
role: RecipientRole.SIGNER,
|
role: RecipientRole.SIGNER,
|
||||||
actionAuth: [],
|
actionAuth: [],
|
||||||
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
|
signingOrder: activeRecipientCount + 1,
|
||||||
},
|
|
||||||
{
|
|
||||||
shouldFocus: true,
|
|
||||||
},
|
},
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
void form.trigger('signers');
|
void form.trigger('signers');
|
||||||
@@ -369,18 +385,14 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
|
|
||||||
items.splice(insertIndex, 0, reorderedSigner);
|
items.splice(insertIndex, 0, reorderedSigner);
|
||||||
|
|
||||||
const updatedSigners = items.map((signer, index) => ({
|
const updatedSigners = normalizeSigningOrders(items);
|
||||||
...signer,
|
|
||||||
signingOrder: !canRecipientBeModified(signer.id) ? signer.signingOrder : index + 1,
|
|
||||||
}));
|
|
||||||
|
|
||||||
form.setValue('signers', updatedSigners, {
|
form.setValue('signers', updatedSigners, {
|
||||||
shouldValidate: true,
|
shouldValidate: true,
|
||||||
shouldDirty: true,
|
shouldDirty: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const lastSigner = updatedSigners[updatedSigners.length - 1];
|
if (isAssistantLastSigner(updatedSigners)) {
|
||||||
if (lastSigner.role === RecipientRole.ASSISTANT) {
|
|
||||||
toast({
|
toast({
|
||||||
title: t`Warning: Assistant as last signer`,
|
title: t`Warning: Assistant as last signer`,
|
||||||
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
|
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
|
||||||
@@ -411,18 +423,19 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedSigners = currentSigners.map((signer, idx) => ({
|
const updatedSigners = normalizeSigningOrders(
|
||||||
...signer,
|
currentSigners.map((signer, idx) => ({
|
||||||
role: idx === index ? role : signer.role,
|
...signer,
|
||||||
signingOrder: !canRecipientBeModified(signer.id) ? signer.signingOrder : idx + 1,
|
role: idx === index ? role : signer.role,
|
||||||
}));
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
form.setValue('signers', updatedSigners, {
|
form.setValue('signers', updatedSigners, {
|
||||||
shouldValidate: true,
|
shouldValidate: true,
|
||||||
shouldDirty: true,
|
shouldDirty: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) {
|
if (role === RecipientRole.ASSISTANT && isAssistantLastSigner(updatedSigners)) {
|
||||||
toast({
|
toast({
|
||||||
title: t`Warning: Assistant as last signer`,
|
title: t`Warning: Assistant as last signer`,
|
||||||
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
|
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
|
||||||
@@ -447,22 +460,30 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
const currentSigners = form.getValues('signers');
|
const currentSigners = form.getValues('signers');
|
||||||
const signer = currentSigners[index];
|
const signer = currentSigners[index];
|
||||||
|
|
||||||
// Remove signer from current position and insert at new position
|
if (isCcRecipient(signer)) {
|
||||||
const remainingSigners = currentSigners.filter((_, idx) => idx !== index);
|
return;
|
||||||
const newPosition = Math.min(Math.max(0, newOrder - 1), currentSigners.length - 1);
|
}
|
||||||
remainingSigners.splice(newPosition, 0, signer);
|
|
||||||
|
|
||||||
const updatedSigners = remainingSigners.map((s, idx) => ({
|
const nonCcSigners = currentSigners.filter((s) => !isCcRecipient(s));
|
||||||
...s,
|
const ccSigners = currentSigners.filter((s) => isCcRecipient(s));
|
||||||
signingOrder: !canRecipientBeModified(s.id) ? s.signingOrder : idx + 1,
|
const currentSigningOrderIndex = nonCcSigners.findIndex((s) => s.formId === signer.formId);
|
||||||
}));
|
|
||||||
|
if (currentSigningOrderIndex === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [reorderedSigner] = nonCcSigners.splice(currentSigningOrderIndex, 1);
|
||||||
|
const newPosition = Math.min(Math.max(0, newOrder - 1), nonCcSigners.length);
|
||||||
|
nonCcSigners.splice(newPosition, 0, reorderedSigner);
|
||||||
|
|
||||||
|
const updatedSigners = normalizeSigningOrders([...nonCcSigners, ...ccSigners]);
|
||||||
|
|
||||||
form.setValue('signers', updatedSigners, {
|
form.setValue('signers', updatedSigners, {
|
||||||
shouldValidate: true,
|
shouldValidate: true,
|
||||||
shouldDirty: true,
|
shouldDirty: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) {
|
if (signer.role === RecipientRole.ASSISTANT && isAssistantLastSigner(updatedSigners)) {
|
||||||
toast({
|
toast({
|
||||||
title: t`Warning: Assistant as last signer`,
|
title: t`Warning: Assistant as last signer`,
|
||||||
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
|
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
|
||||||
@@ -476,10 +497,12 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
setShowSigningOrderConfirmation(false);
|
setShowSigningOrderConfirmation(false);
|
||||||
|
|
||||||
const currentSigners = form.getValues('signers');
|
const currentSigners = form.getValues('signers');
|
||||||
const updatedSigners = currentSigners.map((signer) => ({
|
const updatedSigners = normalizeSigningOrders(
|
||||||
...signer,
|
currentSigners.map((signer) => ({
|
||||||
role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role,
|
...signer,
|
||||||
}));
|
role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
form.setValue('signers', updatedSigners, {
|
form.setValue('signers', updatedSigners, {
|
||||||
shouldValidate: true,
|
shouldValidate: true,
|
||||||
@@ -796,6 +819,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
isDragDisabled={
|
isDragDisabled={
|
||||||
!isSigningOrderSequential ||
|
!isSigningOrderSequential ||
|
||||||
isSubmitting ||
|
isSubmitting ||
|
||||||
|
isCcRecipient(signer) ||
|
||||||
!canRecipientBeModified(signer.id) ||
|
!canRecipientBeModified(signer.id) ||
|
||||||
!signer.signingOrder
|
!signer.signingOrder
|
||||||
}
|
}
|
||||||
@@ -819,7 +843,11 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row items-center gap-x-2">
|
<div className="flex flex-row items-center gap-x-2">
|
||||||
{isSigningOrderSequential && (
|
{isSigningOrderSequential && isCcRecipient(signer) && (
|
||||||
|
<div className="mt-auto h-10 w-[4.25rem] flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSigningOrderSequential && !isCcRecipient(signer) && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name={`signers.${index}.signingOrder`}
|
name={`signers.${index}.signingOrder`}
|
||||||
@@ -835,7 +863,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
max={signers.length}
|
max={activeRecipientCount}
|
||||||
data-testid="signing-order-input"
|
data-testid="signing-order-input"
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-10 text-center',
|
'w-10 text-center',
|
||||||
@@ -976,7 +1004,6 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
handleRoleChange(index, value as RecipientRole);
|
handleRoleChange(index, value as RecipientRole);
|
||||||
field.onChange(value);
|
|
||||||
}}
|
}}
|
||||||
disabled={
|
disabled={
|
||||||
snapshot.isDragging || isSubmitting || !canRecipientBeModified(signer.id)
|
snapshot.isDragging || isSubmitting || !canRecipientBeModified(signer.id)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from
|
|||||||
|
|
||||||
import { EnvelopeItemDeleteDialog } from '~/components/dialogs/envelope-item-delete-dialog';
|
import { EnvelopeItemDeleteDialog } from '~/components/dialogs/envelope-item-delete-dialog';
|
||||||
|
|
||||||
|
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
|
||||||
import { EnvelopeEditorRecipientForm } from './envelope-editor-recipient-form';
|
import { EnvelopeEditorRecipientForm } from './envelope-editor-recipient-form';
|
||||||
import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
|
import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
|
||||||
|
|
||||||
@@ -449,6 +450,9 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl space-y-6 p-8">
|
<div className="mx-auto max-w-4xl space-y-6 p-8">
|
||||||
<input {...getReplaceInputProps()} />
|
<input {...getReplaceInputProps()} />
|
||||||
|
|
||||||
|
<EnvelopeEditorInvalidDirectTemplateAlert className="max-w-none" />
|
||||||
|
|
||||||
<Card backdropBlur={false} className="border">
|
<Card backdropBlur={false} className="border">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
|
||||||
|
import { isAdmin } from '@documenso/lib/utils/is-admin';
|
||||||
|
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
|
import type { TAdminSearchResultType } from '@documenso/trpc/server/admin-router/admin-search.types';
|
||||||
|
import { ADMIN_SEARCH_MAX_QUERY_LENGTH } from '@documenso/trpc/server/admin-router/admin-search.types';
|
||||||
|
|
||||||
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { keepPreviousData } from '@tanstack/react-query';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import { ArrowRightIcon, Building2Icon, CreditCardIcon, FileTextIcon, UserIcon, UsersIcon } from 'lucide-react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { PromptCategory, PromptItem } from './app-command-menu.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maximum number of results the admin search returns per resource type.
|
||||||
|
*/
|
||||||
|
const ADMIN_SEARCH_RESULTS_CAP = 5;
|
||||||
|
|
||||||
|
const ADMIN_GROUP_LABELS: Record<TAdminSearchResultType, MessageDescriptor> = {
|
||||||
|
document: msg`Documents`,
|
||||||
|
user: msg`Users`,
|
||||||
|
organisation: msg`Organisations`,
|
||||||
|
team: msg`Teams`,
|
||||||
|
recipient: msg`Recipients`,
|
||||||
|
subscription: msg`Subscriptions`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ADMIN_GROUP_ICONS: Record<TAdminSearchResultType, LucideIcon> = {
|
||||||
|
document: FileTextIcon,
|
||||||
|
user: UserIcon,
|
||||||
|
organisation: Building2Icon,
|
||||||
|
team: UsersIcon,
|
||||||
|
recipient: UserIcon,
|
||||||
|
subscription: CreditCardIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin list pages which support prefilling their search from the URL, used
|
||||||
|
* for the "View all results" links on capped groups. Teams, recipients and
|
||||||
|
* subscriptions have no admin list pages.
|
||||||
|
*/
|
||||||
|
const ADMIN_GROUP_LIST_PATHS: Partial<Record<TAdminSearchResultType, (_query: string) => string>> = {
|
||||||
|
document: (query) => `/admin/documents?term=${encodeURIComponent(query)}`,
|
||||||
|
user: (query) => `/admin/users?search=${encodeURIComponent(query)}`,
|
||||||
|
organisation: (query) => `/admin/organisations?query=${encodeURIComponent(query)}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseAdminSearchCategoriesOptions = {
|
||||||
|
/**
|
||||||
|
* The trimmed, debounced search query.
|
||||||
|
*/
|
||||||
|
query: string;
|
||||||
|
open: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The isolated admin portion of the command prompt: searches every admin
|
||||||
|
* resource and maps the results to prompt categories marked as global.
|
||||||
|
*
|
||||||
|
* Returns no categories and never queries for non admin users. The admin
|
||||||
|
* search endpoint is additionally guarded server side by the admin procedure.
|
||||||
|
*/
|
||||||
|
export const useAdminSearchCategories = ({ query, open }: UseAdminSearchCategoriesOptions) => {
|
||||||
|
const { user } = useSession();
|
||||||
|
|
||||||
|
const isUserAdmin = isAdmin(user);
|
||||||
|
|
||||||
|
// Admin searches hit every resource table, so require a longer query unless
|
||||||
|
// it is a number, which could be a resource ID of any length. Queries over
|
||||||
|
// the endpoint's length limit are skipped entirely instead of being sent
|
||||||
|
// and rejected.
|
||||||
|
const hasValidAdminSearch =
|
||||||
|
isUserAdmin && query.length <= ADMIN_SEARCH_MAX_QUERY_LENGTH && (query.length > 3 || /^\d+$/.test(query));
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: adminSearchData,
|
||||||
|
isFetching,
|
||||||
|
isError,
|
||||||
|
} = trpcReact.admin.search.useQuery(
|
||||||
|
{
|
||||||
|
query,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: open && hasValidAdminSearch,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
// Retyping is the retry in a search-as-you-type flow: fail fast so the
|
||||||
|
// prompt can surface an honest error state instead of retrying.
|
||||||
|
retry: false,
|
||||||
|
...SKIP_QUERY_BATCH_META,
|
||||||
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const categories = useMemo((): PromptCategory[] => {
|
||||||
|
if (!hasValidAdminSearch || !adminSearchData) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return adminSearchData.groups.map((group) => {
|
||||||
|
const isCapped = group.results.length >= ADMIN_SEARCH_RESULTS_CAP;
|
||||||
|
const buildListPath = ADMIN_GROUP_LIST_PATHS[group.type];
|
||||||
|
|
||||||
|
const items: PromptItem[] = group.results.map((result) => ({
|
||||||
|
id: `admin-${group.type}-${result.value}`,
|
||||||
|
label: result.label,
|
||||||
|
sublabel: result.sublabel,
|
||||||
|
path: result.path,
|
||||||
|
icon: ADMIN_GROUP_ICONS[group.type],
|
||||||
|
initials: group.type === 'user' || group.type === 'recipient' ? extractInitials(result.label) : undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Capped groups link to the full admin list page with the search
|
||||||
|
// prefilled so the cap is never a dead end.
|
||||||
|
if (isCapped && buildListPath) {
|
||||||
|
items.push({
|
||||||
|
id: `admin-${group.type}-view-all`,
|
||||||
|
label: msg`View all results`,
|
||||||
|
path: buildListPath(query),
|
||||||
|
icon: ArrowRightIcon,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `admin-${group.type}`,
|
||||||
|
label: ADMIN_GROUP_LABELS[group.type],
|
||||||
|
items,
|
||||||
|
count: group.results.length,
|
||||||
|
chipCount: group.results.length,
|
||||||
|
isCapped,
|
||||||
|
isGlobal: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [hasValidAdminSearch, adminSearchData, query]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isUserAdmin,
|
||||||
|
categories,
|
||||||
|
isFetching,
|
||||||
|
isError,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Bird, CheckCircle2, XCircle } from 'lucide-react';
|
import { Bird, CheckCircle2, TimerOff, XCircle } from 'lucide-react';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
export type DocumentsTableEmptyStateProps = { status: ExtendedDocumentStatus };
|
export type DocumentsTableEmptyStateProps = { status: ExtendedDocumentStatus };
|
||||||
@@ -29,6 +29,16 @@ export const DocumentsTableEmptyState = ({ status }: DocumentsTableEmptyStatePro
|
|||||||
message: msg`There are no cancelled documents. Documents you cancel will remain here as a record that they were distributed.`,
|
message: msg`There are no cancelled documents. Documents you cancel will remain here as a record that they were distributed.`,
|
||||||
icon: XCircle,
|
icon: XCircle,
|
||||||
}))
|
}))
|
||||||
|
.with(ExtendedDocumentStatus.REJECTED, () => ({
|
||||||
|
title: msg`No rejected documents`,
|
||||||
|
message: msg`There are no rejected documents. Documents that a recipient declines to sign will appear here.`,
|
||||||
|
icon: XCircle,
|
||||||
|
}))
|
||||||
|
.with(ExtendedDocumentStatus.EXPIRED, () => ({
|
||||||
|
title: msg`No expired documents`,
|
||||||
|
message: msg`There are no documents with expired signing links. You can redistribute a document to renew its expiration.`,
|
||||||
|
icon: TimerOff,
|
||||||
|
}))
|
||||||
.with(ExtendedDocumentStatus.ALL, () => ({
|
.with(ExtendedDocumentStatus.ALL, () => ({
|
||||||
title: msg`We're all empty`,
|
title: msg`We're all empty`,
|
||||||
message: msg`You have not yet created or received any documents. To create a document please upload one.`,
|
message: msg`You have not yet created or received any documents. To create a document please upload one.`,
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export default function OrganisationSettingsDocumentPage() {
|
|||||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
delegateDocumentOwnership: delegateDocumentOwnership,
|
delegateDocumentOwnership,
|
||||||
aiFeaturesEnabled,
|
aiFeaturesEnabled,
|
||||||
envelopeExpirationPeriod: envelopeExpirationPeriod ?? undefined,
|
envelopeExpirationPeriod: envelopeExpirationPeriod ?? undefined,
|
||||||
reminderSettings: reminderSettings ?? undefined,
|
reminderSettings: reminderSettings ?? undefined,
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export default function DocumentsPage() {
|
|||||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||||
[ExtendedDocumentStatus.REJECTED]: 0,
|
[ExtendedDocumentStatus.REJECTED]: 0,
|
||||||
[ExtendedDocumentStatus.CANCELLED]: 0,
|
[ExtendedDocumentStatus.CANCELLED]: 0,
|
||||||
|
[ExtendedDocumentStatus.EXPIRED]: 0,
|
||||||
[ExtendedDocumentStatus.INBOX]: 0,
|
[ExtendedDocumentStatus.INBOX]: 0,
|
||||||
[ExtendedDocumentStatus.ALL]: 0,
|
[ExtendedDocumentStatus.ALL]: 0,
|
||||||
});
|
});
|
||||||
@@ -157,6 +158,8 @@ export default function DocumentsPage() {
|
|||||||
ExtendedDocumentStatus.COMPLETED,
|
ExtendedDocumentStatus.COMPLETED,
|
||||||
ExtendedDocumentStatus.CANCELLED,
|
ExtendedDocumentStatus.CANCELLED,
|
||||||
ExtendedDocumentStatus.DRAFT,
|
ExtendedDocumentStatus.DRAFT,
|
||||||
|
ExtendedDocumentStatus.REJECTED,
|
||||||
|
ExtendedDocumentStatus.EXPIRED,
|
||||||
ExtendedDocumentStatus.ALL,
|
ExtendedDocumentStatus.ALL,
|
||||||
]
|
]
|
||||||
.filter((value) => {
|
.filter((value) => {
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export default function TeamsSettingsPage() {
|
|||||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
}),
|
}),
|
||||||
delegateDocumentOwnership: delegateDocumentOwnership,
|
delegateDocumentOwnership,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/
|
|||||||
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
|
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
|
||||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
|
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { Plural } from '@lingui/react/macro';
|
import { Plural } from '@lingui/react/macro';
|
||||||
import { UsersIcon } from 'lucide-react';
|
import { UsersIcon } from 'lucide-react';
|
||||||
@@ -14,6 +15,7 @@ import { redirect } from 'react-router';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
|
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
|
||||||
|
import { DirectTemplateInvalidPageView } from '~/components/general/direct-template/direct-template-invalid-page';
|
||||||
import { DirectTemplatePageView } from '~/components/general/direct-template/direct-template-page';
|
import { DirectTemplatePageView } from '~/components/general/direct-template/direct-template-page';
|
||||||
import { DirectTemplateAuthPageView } from '~/components/general/direct-template/direct-template-signing-auth-page';
|
import { DirectTemplateAuthPageView } from '~/components/general/direct-template/direct-template-signing-auth-page';
|
||||||
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
|
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
|
||||||
@@ -70,8 +72,18 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const recipientsWithMissingFields = getRecipientsWithMissingFields(template.recipients, template.fields);
|
||||||
|
|
||||||
|
if (recipientsWithMissingFields.length > 0) {
|
||||||
|
return {
|
||||||
|
isAccessAuthValid: true,
|
||||||
|
isTemplateMissingSignatures: true,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isAccessAuthValid: true,
|
isAccessAuthValid: true,
|
||||||
|
isTemplateMissingSignatures: false,
|
||||||
template: {
|
template: {
|
||||||
...template,
|
...template,
|
||||||
folder: null,
|
folder: null,
|
||||||
@@ -96,6 +108,7 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
|
|||||||
.then((envelopeForSigning) => {
|
.then((envelopeForSigning) => {
|
||||||
return {
|
return {
|
||||||
isDocumentAccessValid: true,
|
isDocumentAccessValid: true,
|
||||||
|
isTemplateMissingSignatures: false,
|
||||||
envelopeForSigning,
|
envelopeForSigning,
|
||||||
} as const;
|
} as const;
|
||||||
})
|
})
|
||||||
@@ -108,6 +121,13 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
|
|||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.MISSING_SIGNATURE_FIELD) {
|
||||||
|
return {
|
||||||
|
isDocumentAccessValid: true,
|
||||||
|
isTemplateMissingSignatures: true,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
throw new Response('Not Found', { status: 404 });
|
throw new Response('Not Found', { status: 404 });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -181,6 +201,10 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV
|
|||||||
return <DirectTemplateAuthPageView />;
|
return <DirectTemplateAuthPageView />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.isTemplateMissingSignatures) {
|
||||||
|
return <DirectTemplateInvalidPageView />;
|
||||||
|
}
|
||||||
|
|
||||||
const { template, directTemplateRecipient } = data;
|
const { template, directTemplateRecipient } = data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -235,6 +259,10 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
|
|||||||
return <DocumentSigningAuthPageView email={''} emailHasAccount={true} />;
|
return <DocumentSigningAuthPageView email={''} emailHasAccount={true} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.isTemplateMissingSignatures) {
|
||||||
|
return <DirectTemplateInvalidPageView />;
|
||||||
|
}
|
||||||
|
|
||||||
const { envelope, recipient } = data.envelopeForSigning;
|
const { envelope, recipient } = data.envelopeForSigning;
|
||||||
|
|
||||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||||
|
|||||||
@@ -1,98 +1,15 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { redirect } from 'react-router';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { OrganisationMemberInviteStatus } from '@prisma/client';
|
|
||||||
import { Link } from 'react-router';
|
|
||||||
|
|
||||||
import type { Route } from './+types/organisation.decline.$token';
|
import type { Route } from './+types/organisation.decline.$token';
|
||||||
|
|
||||||
export async function loader({ params }: Route.LoaderArgs) {
|
export function loader({ params }: Route.LoaderArgs) {
|
||||||
const { token } = params;
|
const { token } = params;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return {
|
throw redirect('/');
|
||||||
state: 'InvalidLink',
|
|
||||||
} as const;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const organisationMemberInvite = await prisma.organisationMemberInvite.findUnique({
|
// Declining now happens on the invite page via tRPC. Redirect there with the
|
||||||
where: {
|
// `action=decline` flag so it renders the decline-only view (no accept).
|
||||||
token,
|
throw redirect(`/organisation/invite/${token}?action=decline`);
|
||||||
},
|
|
||||||
include: {
|
|
||||||
organisation: {
|
|
||||||
select: {
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!organisationMemberInvite) {
|
|
||||||
return {
|
|
||||||
state: 'InvalidLink',
|
|
||||||
} as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (organisationMemberInvite.status !== OrganisationMemberInviteStatus.DECLINED) {
|
|
||||||
await prisma.organisationMemberInvite.update({
|
|
||||||
where: {
|
|
||||||
id: organisationMemberInvite.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
status: OrganisationMemberInviteStatus.DECLINED,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
state: 'Success',
|
|
||||||
organisationName: organisationMemberInvite.organisation.name,
|
|
||||||
} as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DeclineInvitationPage({ loaderData }: Route.ComponentProps) {
|
|
||||||
const data = loaderData;
|
|
||||||
|
|
||||||
if (data.state === 'InvalidLink') {
|
|
||||||
return (
|
|
||||||
<div className="w-screen max-w-lg px-4">
|
|
||||||
<div className="w-full">
|
|
||||||
<h1 className="font-semibold text-4xl">
|
|
||||||
<Trans>Invalid token</Trans>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
|
||||||
<Trans>This token is invalid or has expired. No action is needed.</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button asChild>
|
|
||||||
<Link to="/">
|
|
||||||
<Trans>Return</Trans>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-screen max-w-lg px-4">
|
|
||||||
<h1 className="font-semibold text-4xl">
|
|
||||||
<Trans>Invitation declined</Trans>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
|
||||||
<Trans>
|
|
||||||
You have declined the invitation from <strong>{data.organisationName}</strong> to join their organisation.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button asChild>
|
|
||||||
<Link to="/">
|
|
||||||
<Trans>Return to Home</Trans>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
import { acceptOrganisationInvitation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation';
|
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
import { Link } from 'react-router';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import { OrganisationMemberInviteStatus } from '@prisma/client';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useSearchParams } from 'react-router';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import type { Route } from './+types/organisation.invite.$token';
|
import type { Route } from './+types/organisation.invite.$token';
|
||||||
|
|
||||||
@@ -37,6 +43,22 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const organisationName = organisationMemberInvite.organisation.name;
|
||||||
|
|
||||||
|
if (organisationMemberInvite.status === OrganisationMemberInviteStatus.ACCEPTED) {
|
||||||
|
return {
|
||||||
|
state: 'AlreadyAccepted',
|
||||||
|
organisationName,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (organisationMemberInvite.status === OrganisationMemberInviteStatus.DECLINED) {
|
||||||
|
return {
|
||||||
|
state: 'AlreadyDeclined',
|
||||||
|
organisationName,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
email: {
|
email: {
|
||||||
@@ -49,26 +71,13 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Directly convert the team member invite to a team member if they already have an account.
|
|
||||||
if (user) {
|
|
||||||
await acceptOrganisationInvitation({ token: organisationMemberInvite.token });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return {
|
|
||||||
state: 'LoginRequired',
|
|
||||||
email: organisationMemberInvite.email,
|
|
||||||
organisationName: organisationMemberInvite.organisation.name,
|
|
||||||
} as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSessionUserTheInvitedUser = user.id === session.user?.id;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state: 'Success',
|
state: 'Pending',
|
||||||
|
token: organisationMemberInvite.token,
|
||||||
email: organisationMemberInvite.email,
|
email: organisationMemberInvite.email,
|
||||||
organisationName: organisationMemberInvite.organisation.name,
|
organisationName,
|
||||||
isSessionUserTheInvitedUser,
|
userExists: user !== null,
|
||||||
|
isSessionUserTheInvitedUser: user !== null && user.id === session.user?.id,
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,57 +106,253 @@ export default function AcceptInvitationPage({ loaderData }: Route.ComponentProp
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.state === 'LoginRequired') {
|
if (data.state === 'AlreadyAccepted') {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="w-screen max-w-lg px-4">
|
||||||
<h1 className="font-semibold text-4xl">
|
<div className="w-full">
|
||||||
<Trans>Organisation invitation</Trans>
|
<h1 className="font-semibold text-4xl">
|
||||||
</h1>
|
<Trans>Invitation already accepted</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
<p className="mt-2 text-muted-foreground text-sm">
|
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||||
<Trans>
|
<Trans>
|
||||||
You have been invited by <strong>{data.organisationName}</strong> to join their organisation.
|
You are already a member of <strong>{data.organisationName}</strong>.
|
||||||
</Trans>
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="mt-1 mb-4 text-muted-foreground text-sm">
|
<Button asChild>
|
||||||
<Trans>To accept this invitation you must create an account.</Trans>
|
<Link to="/">
|
||||||
</p>
|
<Trans>Continue</Trans>
|
||||||
|
</Link>
|
||||||
<Button asChild>
|
</Button>
|
||||||
<Link to={`/signup#email=${encodeURIComponent(data.email)}`}>
|
</div>
|
||||||
<Trans>Create account</Trans>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.state === 'AlreadyDeclined') {
|
||||||
|
return <InvitationDeclined organisationName={data.organisationName} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<PendingInvitation
|
||||||
<h1 className="font-semibold text-4xl">
|
token={data.token}
|
||||||
<Trans>Invitation accepted!</Trans>
|
email={data.email}
|
||||||
</h1>
|
organisationName={data.organisationName}
|
||||||
|
userExists={data.userExists}
|
||||||
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
isSessionUserTheInvitedUser={data.isSessionUserTheInvitedUser}
|
||||||
<Trans>
|
/>
|
||||||
You have accepted an invitation from <strong>{data.organisationName}</strong> to join their organisation.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{data.isSessionUserTheInvitedUser ? (
|
|
||||||
<Button asChild>
|
|
||||||
<Link to="/">
|
|
||||||
<Trans>Continue</Trans>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button asChild>
|
|
||||||
<Link to={`/signin#email=${encodeURIComponent(data.email)}`}>
|
|
||||||
<Trans>Continue to login</Trans>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PendingInvitationProps = {
|
||||||
|
token: string;
|
||||||
|
email: string;
|
||||||
|
organisationName: string;
|
||||||
|
userExists: boolean;
|
||||||
|
isSessionUserTheInvitedUser: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InvitationResult = 'idle' | 'accepted' | 'declined';
|
||||||
|
|
||||||
|
type AcceptFailureReason = 'CapExceeded' | 'SubscriptionInactive' | 'Unknown';
|
||||||
|
|
||||||
|
const PendingInvitation = ({
|
||||||
|
token,
|
||||||
|
email,
|
||||||
|
organisationName,
|
||||||
|
userExists,
|
||||||
|
isSessionUserTheInvitedUser,
|
||||||
|
}: PendingInvitationProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { refreshSession } = useOptionalSession();
|
||||||
|
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const actionIsDecline = searchParams.get('action') === 'decline';
|
||||||
|
|
||||||
|
const [result, setResult] = useState<InvitationResult>('idle');
|
||||||
|
const [acceptFailureReason, setAcceptFailureReason] = useState<AcceptFailureReason | null>(null);
|
||||||
|
|
||||||
|
const acceptInvitation = trpc.organisation.member.invite.accept.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await refreshSession();
|
||||||
|
|
||||||
|
setResult('accepted');
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
const failureReason = match(error.code)
|
||||||
|
.with(AppErrorCode.LIMIT_EXCEEDED, () => 'CapExceeded' as const)
|
||||||
|
.with('SUBSCRIPTION_INACTIVE', () => 'SubscriptionInactive' as const)
|
||||||
|
.otherwise(() => 'Unknown' as const);
|
||||||
|
|
||||||
|
setAcceptFailureReason(failureReason);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const declineInvitation = trpc.organisation.member.invite.decline.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await refreshSession();
|
||||||
|
|
||||||
|
setResult('declined');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: t`Something went wrong`,
|
||||||
|
description: t`Unable to decline this invitation at this time.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 10000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result === 'accepted') {
|
||||||
|
return (
|
||||||
|
<div className="w-screen max-w-lg px-4">
|
||||||
|
<div className="w-full">
|
||||||
|
<h1 className="font-semibold text-4xl">
|
||||||
|
<Trans>Invitation accepted!</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||||
|
<Trans>
|
||||||
|
You have accepted an invitation from <strong>{organisationName}</strong> to join their organisation.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isSessionUserTheInvitedUser ? (
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/">
|
||||||
|
<Trans>Continue</Trans>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button asChild>
|
||||||
|
<Link to={`/signin#email=${encodeURIComponent(email)}`}>
|
||||||
|
<Trans>Continue to login</Trans>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result === 'declined') {
|
||||||
|
return <InvitationDeclined organisationName={organisationName} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accepting requires an account (acceptance keys off the invited email).
|
||||||
|
// Declining does not, so we only gate account creation on the accept flow.
|
||||||
|
if (!actionIsDecline && !userExists) {
|
||||||
|
return (
|
||||||
|
<div className="w-screen max-w-lg px-4">
|
||||||
|
<div className="w-full">
|
||||||
|
<h1 className="font-semibold text-4xl">
|
||||||
|
<Trans>Organisation invitation</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-2 text-muted-foreground text-sm">
|
||||||
|
<Trans>
|
||||||
|
You have been invited by <strong>{organisationName}</strong> to join their organisation.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mt-1 mb-4 text-muted-foreground text-sm">
|
||||||
|
<Trans>To accept this invitation you must create an account.</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button asChild>
|
||||||
|
<Link to={`/signup#email=${encodeURIComponent(email)}`}>
|
||||||
|
<Trans>Create account</Trans>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPending = acceptInvitation.isPending || declineInvitation.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-screen max-w-lg px-4">
|
||||||
|
<div className="w-full">
|
||||||
|
<h1 className="font-semibold text-4xl">
|
||||||
|
<Trans>Organisation invitation</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||||
|
<Trans>
|
||||||
|
You have been invited to join <strong>{organisationName}</strong> on Documenso.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{acceptFailureReason && (
|
||||||
|
<p className="mt-2 mb-4 text-destructive text-sm">
|
||||||
|
{match(acceptFailureReason)
|
||||||
|
.with('CapExceeded', () => (
|
||||||
|
<Trans>
|
||||||
|
<strong>{organisationName}</strong> has reached its member limit. Please contact the organisation
|
||||||
|
administrator to upgrade their plan before accepting this invitation.
|
||||||
|
</Trans>
|
||||||
|
))
|
||||||
|
.with('SubscriptionInactive', () => (
|
||||||
|
<Trans>
|
||||||
|
<strong>{organisationName}</strong> does not have an active subscription. Please contact the
|
||||||
|
organisation administrator to renew their plan before accepting this invitation.
|
||||||
|
</Trans>
|
||||||
|
))
|
||||||
|
.with('Unknown', () => (
|
||||||
|
<Trans>
|
||||||
|
We were unable to add you to <strong>{organisationName}</strong> at this time. Please try again later,
|
||||||
|
or contact the organisation administrator.
|
||||||
|
</Trans>
|
||||||
|
))
|
||||||
|
.exhaustive()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={async () => declineInvitation.mutateAsync({ token })}
|
||||||
|
loading={declineInvitation.isPending}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<Trans>Decline</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{!actionIsDecline && (
|
||||||
|
<Button
|
||||||
|
onClick={async () => acceptInvitation.mutateAsync({ token })}
|
||||||
|
loading={acceptInvitation.isPending}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<Trans>Accept</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const InvitationDeclined = ({ organisationName }: { organisationName: string }) => {
|
||||||
|
return (
|
||||||
|
<div className="w-screen max-w-lg px-4">
|
||||||
|
<div className="w-full">
|
||||||
|
<h1 className="font-semibold text-4xl">
|
||||||
|
<Trans>Invitation declined</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||||
|
<Trans>
|
||||||
|
You have declined the invitation from <strong>{organisationName}</strong> to join their organisation.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ export const getDirectTemplateErrorMessage = (code: string): ToastMessageDescrip
|
|||||||
return match(code)
|
return match(code)
|
||||||
.with('RECIPIENT_LIMIT_EXCEEDED', () => RECIPIENT_LIMIT_EXCEEDED_ERROR_MESSAGE)
|
.with('RECIPIENT_LIMIT_EXCEEDED', () => RECIPIENT_LIMIT_EXCEEDED_ERROR_MESSAGE)
|
||||||
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
|
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
|
||||||
|
.with(AppErrorCode.MISSING_SIGNATURE_FIELD, () => ({
|
||||||
|
title: msg`Missing signature fields`,
|
||||||
|
description: msg`This direct link template cannot be used because one or more signers do not have a signature field assigned.`,
|
||||||
|
}))
|
||||||
.otherwise(() => ({
|
.otherwise(() => ({
|
||||||
title: msg`Something went wrong`,
|
title: msg`Something went wrong`,
|
||||||
description: msg`We were unable to submit this document at this time. Please try again later.`,
|
description: msg`We were unable to submit this document at this time. Please try again later.`,
|
||||||
@@ -77,6 +81,10 @@ export const getTemplateUseErrorMessage = (code: string): ToastMessageDescriptor
|
|||||||
title: msg`Error`,
|
title: msg`Error`,
|
||||||
description: msg`The document was created but could not be sent to recipients.`,
|
description: msg`The document was created but could not be sent to recipients.`,
|
||||||
}))
|
}))
|
||||||
|
.with(AppErrorCode.MISSING_SIGNATURE_FIELD, () => ({
|
||||||
|
title: msg`Missing signature fields`,
|
||||||
|
description: msg`The document could not be sent because some signers do not have a signature field. Please edit the template and add a signature field for each signer.`,
|
||||||
|
}))
|
||||||
.with(AppErrorCode.INVALID_BODY, AppErrorCode.INVALID_REQUEST, () => ({
|
.with(AppErrorCode.INVALID_BODY, AppErrorCode.INVALID_REQUEST, () => ({
|
||||||
title: msg`Error`,
|
title: msg`Error`,
|
||||||
description: msg`The document could not be created because of missing or invalid information. Please review the template's recipients and fields.`,
|
description: msg`The document could not be created because of missing or invalid information. Please review the template's recipients and fields.`,
|
||||||
|
|||||||
@@ -106,5 +106,5 @@
|
|||||||
"vite-plugin-babel-macros": "^1.0.6",
|
"vite-plugin-babel-macros": "^1.0.6",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
},
|
},
|
||||||
"version": "2.15.0"
|
"version": "2.16.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+3
-27
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@documenso/root",
|
"name": "@documenso/root",
|
||||||
"version": "2.15.0",
|
"version": "2.16.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@documenso/root",
|
"name": "@documenso/root",
|
||||||
"version": "2.15.0",
|
"version": "2.16.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
@@ -366,7 +366,7 @@
|
|||||||
},
|
},
|
||||||
"apps/remix": {
|
"apps/remix": {
|
||||||
"name": "@documenso/remix",
|
"name": "@documenso/remix",
|
||||||
"version": "2.15.0",
|
"version": "2.16.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cantoo/pdf-lib": "^2.5.3",
|
"@cantoo/pdf-lib": "^2.5.3",
|
||||||
"@documenso/api": "*",
|
"@documenso/api": "*",
|
||||||
@@ -2952,9 +2952,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2972,9 +2969,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2992,9 +2986,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3012,9 +3003,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -25026,9 +25014,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -25045,9 +25030,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -25064,9 +25046,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -25083,9 +25062,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
"apps/*",
|
"apps/*",
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"version": "2.15.0",
|
"version": "2.16.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "patch-package",
|
"postinstall": "patch-package",
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const OpenAPIV1 = Object.assign(
|
|||||||
title: 'Documenso API',
|
title: 'Documenso API',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
description:
|
description:
|
||||||
'API V1 is deprecated, but will continue to be supported. For more details, see https://docs.documenso.com/developers/public-api. \n\nThe Documenso API for retrieving, creating, updating and deleting documents.',
|
'API V1 has been deprecated. For more details, see https://docs.documenso.com/docs/developers/api/migrate-to-envelopes. \n\nThe Documenso API for retrieving, creating, updating and deleting documents.',
|
||||||
},
|
},
|
||||||
servers: [
|
servers: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,439 @@
|
|||||||
|
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { customAlphabet } from 'nanoid';
|
||||||
|
|
||||||
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
import { openCommandMenu } from '../fixtures/command-menu';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
const nanoid = customAlphabet('1234567890abcdef', 10);
|
||||||
|
|
||||||
|
const ADMIN_PROMPT_PLACEHOLDER = 'Search documents, users, organisations…';
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: numeric query shows verified user result and navigates', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
const { user: targetUser } = await seedUser();
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||||
|
|
||||||
|
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(String(targetUser.id));
|
||||||
|
|
||||||
|
await expect(page.getByText('Global Users', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
// The category chips include the admin groups with their result counts.
|
||||||
|
await expect(page.getByRole('button', { name: /Global Users/ })).toBeVisible();
|
||||||
|
|
||||||
|
const userOption = page.getByRole('option').filter({ hasText: targetUser.email }).first();
|
||||||
|
|
||||||
|
// Admin results are real links so they support native link behaviour such
|
||||||
|
// as opening in a new tab.
|
||||||
|
await expect(userOption.getByRole('link')).toHaveAttribute('href', `/admin/users/${targetUser.id}`);
|
||||||
|
|
||||||
|
await userOption.click();
|
||||||
|
|
||||||
|
await page.waitForURL(`/admin/users/${targetUser.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: numeric query shows verified team result and navigates', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
const { team: targetTeam } = await seedUser();
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||||
|
|
||||||
|
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(String(targetTeam.id));
|
||||||
|
|
||||||
|
await expect(page.getByText('Global Teams', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('option').filter({ hasText: targetTeam.url }).first().click();
|
||||||
|
|
||||||
|
await page.waitForURL(`/admin/teams/${targetTeam.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: text query shows document result and navigates', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
const { user: sender, team } = await seedUser();
|
||||||
|
|
||||||
|
const document = await seedPendingDocument(sender, team.id, [], {
|
||||||
|
createDocumentOptions: { title: `admin-ui-search-${nanoid()}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||||
|
|
||||||
|
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(document.title);
|
||||||
|
|
||||||
|
await expect(page.getByText('Global Documents', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('option').filter({ hasText: document.secondaryId }).first().click();
|
||||||
|
|
||||||
|
await page.waitForURL(`/admin/documents/${document.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: envelope_ prefixed query resolves exact document', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
const { user: sender, team } = await seedUser();
|
||||||
|
|
||||||
|
const document = await seedPendingDocument(sender, team.id, [], {
|
||||||
|
createDocumentOptions: { title: `admin-ui-search-${nanoid()}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||||
|
|
||||||
|
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(document.id);
|
||||||
|
|
||||||
|
await expect(page.getByText('Global Documents', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole('option').filter({ hasText: document.title }).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: admin search requires more than 3 characters unless numeric', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
|
||||||
|
const adminSearchRequests: string[] = [];
|
||||||
|
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (request.url().includes('admin.search')) {
|
||||||
|
adminSearchRequests.push(request.url());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||||
|
|
||||||
|
const input = page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first();
|
||||||
|
|
||||||
|
// A 3 character non-numeric query must not trigger the admin search. The
|
||||||
|
// personal document search fires for any non-empty query, so its response
|
||||||
|
// is the synchronization anchor proving the debounced queries have fired.
|
||||||
|
const documentSearchResponse = page.waitForResponse((response) => response.url().includes('document.search'));
|
||||||
|
|
||||||
|
await input.fill('abc');
|
||||||
|
|
||||||
|
await documentSearchResponse;
|
||||||
|
|
||||||
|
await expect(page.getByText(/^Global /)).toHaveCount(0);
|
||||||
|
expect(adminSearchRequests).toHaveLength(0);
|
||||||
|
|
||||||
|
// A numeric query fires regardless of length.
|
||||||
|
const adminSearchRequest = page.waitForRequest((request) => request.url().includes('admin.search'));
|
||||||
|
|
||||||
|
await input.fill('7');
|
||||||
|
|
||||||
|
await adminSearchRequest;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: search bar position stays fixed while searching', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
const { user: targetUser } = await seedUser();
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||||
|
|
||||||
|
const input = page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first();
|
||||||
|
|
||||||
|
const initialY = (await input.boundingBox())?.y;
|
||||||
|
|
||||||
|
expect(initialY).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// The height of the prompt may change as results come and go, but the
|
||||||
|
// search bar must never move.
|
||||||
|
await input.fill(String(targetUser.id));
|
||||||
|
|
||||||
|
await expect(page.getByText('Global Users', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
const resultsY = (await input.boundingBox())?.y;
|
||||||
|
|
||||||
|
expect(resultsY).toBe(initialY);
|
||||||
|
|
||||||
|
// The search bar must not move when there are no results at all.
|
||||||
|
await input.fill('zzzz-no-such-thing-9x7q');
|
||||||
|
|
||||||
|
await expect(page.getByText('No results for')).toBeVisible();
|
||||||
|
|
||||||
|
const emptyY = (await input.boundingBox())?.y;
|
||||||
|
|
||||||
|
expect(emptyY).toBe(initialY);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: default view shows the document page links outside a team context', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
// Admin pages have no current team, the page links must still show.
|
||||||
|
await page.goto('/admin/stats');
|
||||||
|
|
||||||
|
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||||
|
|
||||||
|
await expect(page.getByRole('option').filter({ hasText: 'All documents' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('option').filter({ hasText: 'Draft documents' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('option').filter({ hasText: 'All templates' })).toBeVisible();
|
||||||
|
|
||||||
|
// Chips only show for categories with actual results, not for the
|
||||||
|
// hardcoded page links.
|
||||||
|
await expect(page.getByRole('button', { name: /^Documents/ })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('button', { name: /^Templates/ })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('button', { name: /^Settings/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: theme can be changed from the prompt', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||||
|
|
||||||
|
await page.getByRole('option').filter({ hasText: 'Change theme' }).first().click();
|
||||||
|
|
||||||
|
// The sub page has a contextual placeholder and a back option.
|
||||||
|
await expect(page.getByPlaceholder('Search themes…')).toBeVisible();
|
||||||
|
await expect(page.getByRole('option').filter({ hasText: 'Back' }).first()).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.getByRole('option').filter({ hasText: 'Dark Mode' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('option').filter({ hasText: 'Dark Mode' }).first().click();
|
||||||
|
|
||||||
|
await expect(page.locator('html')).toHaveClass(/dark/);
|
||||||
|
|
||||||
|
// The back option returns to the root view.
|
||||||
|
await page.getByRole('option').filter({ hasText: 'Back' }).first().click();
|
||||||
|
|
||||||
|
await expect(page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: capped admin groups offer a view all link', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
|
||||||
|
const namePrefix = `viewall-${nanoid()}`;
|
||||||
|
|
||||||
|
// Seed enough users sharing a name prefix to hit the 5 result cap.
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await seedUser({ name: `${namePrefix}-${i}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||||
|
|
||||||
|
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(namePrefix);
|
||||||
|
|
||||||
|
await expect(page.getByText('Global Users', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
const viewAllOption = page.getByRole('option').filter({ hasText: 'View all results' }).first();
|
||||||
|
|
||||||
|
await expect(viewAllOption.getByRole('link')).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
`/admin/users?search=${encodeURIComponent(namePrefix)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: first result is highlighted after every search', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
const { user: firstUser } = await seedUser();
|
||||||
|
const { user: secondUser } = await seedUser();
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||||
|
|
||||||
|
const input = page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first();
|
||||||
|
|
||||||
|
// First search selects the first result.
|
||||||
|
await input.fill(String(firstUser.id));
|
||||||
|
|
||||||
|
await expect(page.getByRole('option').filter({ hasText: firstUser.email }).first()).toBeVisible();
|
||||||
|
await expect(page.locator('[cmdk-item]').first()).toHaveAttribute('aria-selected', 'true');
|
||||||
|
|
||||||
|
// A subsequent search with entirely new results must select the first
|
||||||
|
// result again.
|
||||||
|
await input.fill(String(secondUser.id));
|
||||||
|
|
||||||
|
await expect(page.getByRole('option').filter({ hasText: secondUser.email }).first()).toBeVisible();
|
||||||
|
await expect(page.locator('[cmdk-item]').first()).toHaveAttribute('aria-selected', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: static items match fuzzy queries', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||||
|
|
||||||
|
// "setg" is a non-contiguous abbreviation of "Settings".
|
||||||
|
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill('setg');
|
||||||
|
|
||||||
|
// Wait for the debounced filter to apply first, "Draft documents" can
|
||||||
|
// never match "setg" under either matching strategy.
|
||||||
|
await expect(page.getByRole('option').filter({ hasText: 'Draft documents' })).toHaveCount(0);
|
||||||
|
|
||||||
|
await expect(page.getByRole('option').filter({ hasText: 'Settings' }).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: page scrollbar is hidden while the prompt is open', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => await page.evaluate(() => getComputedStyle(document.documentElement).overflow))
|
||||||
|
.toBe('hidden');
|
||||||
|
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => await page.evaluate(() => getComputedStyle(document.documentElement).overflow))
|
||||||
|
.toBe('visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: non-admin gets the prompt without the admin search', async ({ page }) => {
|
||||||
|
const { user, team } = await seedUser({ isAdmin: false });
|
||||||
|
|
||||||
|
const document = await seedPendingDocument(user, team.id, []);
|
||||||
|
|
||||||
|
const adminSearchRequests: string[] = [];
|
||||||
|
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (request.url().includes('admin.search')) {
|
||||||
|
adminSearchRequests.push(request.url());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({ page, email: user.email });
|
||||||
|
|
||||||
|
// Non-admins get the same prompt with a non-admin placeholder.
|
||||||
|
await openCommandMenu(page, 'Type a command or search...');
|
||||||
|
|
||||||
|
await expect(page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER)).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByPlaceholder('Type a command or search...').first().fill(document.title);
|
||||||
|
|
||||||
|
// Wait for the regular (non-admin) search to resolve so we know the
|
||||||
|
// debounced queries have fired.
|
||||||
|
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.getByText(/^Global /)).toHaveCount(0);
|
||||||
|
expect(adminSearchRequests).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: typing on a sub page fires no search requests', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
|
||||||
|
const searchRequests: string[] = [];
|
||||||
|
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (/api\/trpc\/(document|template|admin)\.search/.test(request.url())) {
|
||||||
|
searchRequests.push(request.url());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||||
|
|
||||||
|
await page.getByRole('option').filter({ hasText: 'Change theme' }).first().click();
|
||||||
|
|
||||||
|
const input = page.getByPlaceholder('Search themes…');
|
||||||
|
|
||||||
|
await expect(input).toBeVisible();
|
||||||
|
|
||||||
|
// Long enough to pass the admin search threshold if it were enabled.
|
||||||
|
await input.fill('dark');
|
||||||
|
|
||||||
|
// The client-side filter applying proves the typing registered.
|
||||||
|
await expect(page.getByRole('option').filter({ hasText: 'Dark Mode' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('option').filter({ hasText: 'Light Mode' })).toHaveCount(0);
|
||||||
|
|
||||||
|
// Wait out the 200ms search debounce with a wide margin before asserting
|
||||||
|
// that no requests fired: there is no response to anchor on when the
|
||||||
|
// desired behaviour is "no requests at all".
|
||||||
|
await page.waitForTimeout(750);
|
||||||
|
|
||||||
|
expect(searchRequests).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: failed searches show an error state instead of no results', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
|
||||||
|
await page.route(/api\/trpc\/(document|template|admin)\.search/, async (route) => {
|
||||||
|
await route.fulfill({ status: 500, contentType: 'application/json', body: '{}' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||||
|
|
||||||
|
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill('zzzz-no-such-thing-9x7q');
|
||||||
|
|
||||||
|
// A failed search must be honest about it, not claim there are no results.
|
||||||
|
await expect(page.getByText('Something went wrong')).toBeVisible();
|
||||||
|
await expect(page.getByText('No results for')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: partial search failure still shows results with a notice', async ({ page }) => {
|
||||||
|
const { user: adminUser, team } = await seedUser({ isAdmin: true });
|
||||||
|
|
||||||
|
const document = await seedPendingDocument(adminUser, team.id, [], {
|
||||||
|
createDocumentOptions: { title: `partial-fail-${nanoid()}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only the admin search fails: the personal searches succeed.
|
||||||
|
await page.route(/api\/trpc\/admin\.search/, async (route) => {
|
||||||
|
await route.fulfill({ status: 500, contentType: 'application/json', body: '{}' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||||
|
|
||||||
|
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(document.title);
|
||||||
|
|
||||||
|
// The successful personal document search must still render its results.
|
||||||
|
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
|
||||||
|
|
||||||
|
// The failed admin search must be flagged rather than silently dropped.
|
||||||
|
await expect(page.getByText('Some searches failed')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: over-length query skips the admin search without erroring', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
|
||||||
|
const adminSearchRequests: string[] = [];
|
||||||
|
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (request.url().includes('admin.search')) {
|
||||||
|
adminSearchRequests.push(request.url());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||||
|
|
||||||
|
// The admin search endpoint rejects queries longer than 100 characters, so
|
||||||
|
// the client must not send them. The personal searches accept up to 1024
|
||||||
|
// characters and still run, anchoring the debounced query flush.
|
||||||
|
const documentSearchResponse = page.waitForResponse((response) => response.url().includes('document.search'));
|
||||||
|
|
||||||
|
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill('a'.repeat(150));
|
||||||
|
|
||||||
|
await documentSearchResponse;
|
||||||
|
|
||||||
|
// The personal searches ran and found nothing: the honest empty state, with
|
||||||
|
// no error in sight.
|
||||||
|
await expect(page.getByText('No results for')).toBeVisible();
|
||||||
|
await expect(page.getByText('Something went wrong')).toHaveCount(0);
|
||||||
|
|
||||||
|
expect(adminSearchRequests).toHaveLength(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { customAlphabet } from 'nanoid';
|
||||||
|
|
||||||
|
import { apiSignin } from '../../../fixtures/authentication';
|
||||||
|
|
||||||
|
const nanoid = customAlphabet('1234567890abcdef', 10);
|
||||||
|
|
||||||
|
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
type AdminSearchGroup = {
|
||||||
|
type: string;
|
||||||
|
results: Array<{ label: string; sublabel?: string; path: string; value: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const callAdminSearch = async (page: Page, query: string) => {
|
||||||
|
const inputParam = encodeURIComponent(JSON.stringify({ json: { query } }));
|
||||||
|
const url = `${WEBAPP_BASE_URL}/api/trpc/admin.search?input=${inputParam}`;
|
||||||
|
|
||||||
|
const res = await page.context().request.get(url);
|
||||||
|
|
||||||
|
return {
|
||||||
|
res,
|
||||||
|
groups: res.ok()
|
||||||
|
? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
((await res.json()).result.data.json.groups as AdminSearchGroup[])
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const findGroup = (groups: AdminSearchGroup[] | null, type: string) =>
|
||||||
|
(groups ?? []).find((group) => group.type === type);
|
||||||
|
|
||||||
|
// ─── Access control ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('[ADMIN][TRPC][SEARCH]: unauthenticated request is rejected with 401', async ({ page }) => {
|
||||||
|
const { res } = await callAdminSearch(page, 'anything');
|
||||||
|
|
||||||
|
expect(res.ok()).toBeFalsy();
|
||||||
|
expect(res.status()).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][TRPC][SEARCH]: non-admin authenticated user is rejected with 401', async ({ page }) => {
|
||||||
|
const { user: nonAdminUser } = await seedUser({ isAdmin: false });
|
||||||
|
|
||||||
|
await apiSignin({ page, email: nonAdminUser.email });
|
||||||
|
|
||||||
|
const { res } = await callAdminSearch(page, 'anything');
|
||||||
|
|
||||||
|
expect(res.ok()).toBeFalsy();
|
||||||
|
expect(res.status()).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Numeric queries: verified ID lookups ────────────────────────────────────
|
||||||
|
|
||||||
|
test('[ADMIN][TRPC][SEARCH]: numeric query returns verified user and team rows', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
const { user: targetUser, team: targetTeam } = await seedUser();
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
// Search by user ID.
|
||||||
|
const userSearch = await callAdminSearch(page, String(targetUser.id));
|
||||||
|
|
||||||
|
expect(userSearch.res.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const userGroup = findGroup(userSearch.groups, 'user');
|
||||||
|
expect(userGroup).toBeDefined();
|
||||||
|
expect(userGroup?.results).toHaveLength(1);
|
||||||
|
expect(userGroup?.results[0].path).toBe(`/admin/users/${targetUser.id}`);
|
||||||
|
expect(userGroup?.results[0].sublabel).toContain(targetUser.email);
|
||||||
|
|
||||||
|
// The cmdk `value` contract: value must contain the raw query.
|
||||||
|
expect(userGroup?.results[0].value).toContain(String(targetUser.id));
|
||||||
|
|
||||||
|
// Search by team ID.
|
||||||
|
const teamSearch = await callAdminSearch(page, String(targetTeam.id));
|
||||||
|
|
||||||
|
expect(teamSearch.res.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const teamGroup = findGroup(teamSearch.groups, 'team');
|
||||||
|
expect(teamGroup).toBeDefined();
|
||||||
|
expect(teamGroup?.results).toHaveLength(1);
|
||||||
|
expect(teamGroup?.results[0].path).toBe(`/admin/teams/${targetTeam.id}`);
|
||||||
|
expect(teamGroup?.results[0].label).toBe(targetTeam.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][TRPC][SEARCH]: numeric query returns verified document and recipient rows', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
const { user: sender, team } = await seedUser();
|
||||||
|
const { user: recipientUser } = await seedUser();
|
||||||
|
|
||||||
|
const document = await seedPendingDocument(sender, team.id, [recipientUser]);
|
||||||
|
const legacyDocumentId = document.secondaryId.replace('document_', '');
|
||||||
|
const recipient = document.recipients[0];
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
// Search by legacy document ID (bare number).
|
||||||
|
const documentSearch = await callAdminSearch(page, legacyDocumentId);
|
||||||
|
|
||||||
|
expect(documentSearch.res.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const documentGroup = findGroup(documentSearch.groups, 'document');
|
||||||
|
expect(documentGroup).toBeDefined();
|
||||||
|
expect(documentGroup?.results).toHaveLength(1);
|
||||||
|
expect(documentGroup?.results[0].path).toBe(`/admin/documents/${document.id}`);
|
||||||
|
expect(documentGroup?.results[0].label).toBe(document.title);
|
||||||
|
|
||||||
|
// Search by recipient ID: links to the parent document.
|
||||||
|
const recipientSearch = await callAdminSearch(page, String(recipient.id));
|
||||||
|
|
||||||
|
expect(recipientSearch.res.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const recipientGroup = findGroup(recipientSearch.groups, 'recipient');
|
||||||
|
expect(recipientGroup).toBeDefined();
|
||||||
|
expect(recipientGroup?.results).toHaveLength(1);
|
||||||
|
expect(recipientGroup?.results[0].path).toBe(`/admin/documents/${document.id}`);
|
||||||
|
expect(recipientGroup?.results[0].label).toBe(recipient.email);
|
||||||
|
expect(recipientGroup?.results[0].sublabel).toBe(`#${recipient.id} · ${recipient.name} · ${document.title}`);
|
||||||
|
|
||||||
|
// Search by the full document_<id> secondary ID: exercises the prefix branch.
|
||||||
|
const secondaryIdSearch = await callAdminSearch(page, document.secondaryId);
|
||||||
|
|
||||||
|
expect(secondaryIdSearch.res.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const secondaryIdGroup = findGroup(secondaryIdSearch.groups, 'document');
|
||||||
|
expect(secondaryIdGroup).toBeDefined();
|
||||||
|
expect(secondaryIdGroup?.results[0].path).toBe(`/admin/documents/${document.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][TRPC][SEARCH]: numeric query with no matches returns no groups', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
const { res, groups } = await callAdminSearch(page, '999999999');
|
||||||
|
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
expect(groups).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][TRPC][SEARCH]: oversized number does not error and falls back to text search', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
const { user: sender, team } = await seedUser();
|
||||||
|
|
||||||
|
// 99999999999999 exceeds Int4, so it cannot be an ID lookup: it must be
|
||||||
|
// treated as text (and must not 500).
|
||||||
|
const oversizedNumber = '99999999999999';
|
||||||
|
|
||||||
|
const document = await seedPendingDocument(sender, team.id, [], {
|
||||||
|
createDocumentOptions: { title: `${oversizedNumber}-${nanoid()}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
const { res, groups } = await callAdminSearch(page, oversizedNumber);
|
||||||
|
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const documentGroup = findGroup(groups, 'document');
|
||||||
|
expect(documentGroup).toBeDefined();
|
||||||
|
expect(documentGroup?.results.map((result) => result.path)).toContain(`/admin/documents/${document.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Prefixed ID queries: exact lookups ──────────────────────────────────────
|
||||||
|
|
||||||
|
test('[ADMIN][TRPC][SEARCH]: envelope_ and org_ prefixes resolve exact matches', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
const { user: sender, organisation, team } = await seedUser();
|
||||||
|
|
||||||
|
const document = await seedPendingDocument(sender, team.id, []);
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
// envelope_<id> resolves the document.
|
||||||
|
const envelopeSearch = await callAdminSearch(page, document.id);
|
||||||
|
|
||||||
|
expect(envelopeSearch.res.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const documentGroup = findGroup(envelopeSearch.groups, 'document');
|
||||||
|
expect(documentGroup).toBeDefined();
|
||||||
|
expect(documentGroup?.results[0].path).toBe(`/admin/documents/${document.id}`);
|
||||||
|
|
||||||
|
// Only the document group is returned for a recognized prefix.
|
||||||
|
expect(envelopeSearch.groups).toHaveLength(1);
|
||||||
|
|
||||||
|
// org_<id> resolves the organisation.
|
||||||
|
const orgSearch = await callAdminSearch(page, organisation.id);
|
||||||
|
|
||||||
|
expect(orgSearch.res.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const orgGroup = findGroup(orgSearch.groups, 'organisation');
|
||||||
|
expect(orgGroup).toBeDefined();
|
||||||
|
expect(orgGroup?.results[0].path).toBe(`/admin/organisations/${organisation.id}`);
|
||||||
|
expect(orgGroup?.results[0].label).toBe(organisation.name);
|
||||||
|
|
||||||
|
// Only the organisation group is returned for a recognized prefix.
|
||||||
|
expect(orgSearch.groups).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Free text queries ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('[ADMIN][TRPC][SEARCH]: text query matches documents by title and users by email', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
const { user: sender, team } = await seedUser();
|
||||||
|
|
||||||
|
// A unique title: the default seeded title is shared across the whole suite,
|
||||||
|
// and global search only returns the newest few matches.
|
||||||
|
const document = await seedPendingDocument(sender, team.id, [], {
|
||||||
|
createDocumentOptions: { title: `admin-search-${nanoid()}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
// Search by document title.
|
||||||
|
const titleSearch = await callAdminSearch(page, document.title);
|
||||||
|
|
||||||
|
expect(titleSearch.res.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const documentGroup = findGroup(titleSearch.groups, 'document');
|
||||||
|
expect(documentGroup).toBeDefined();
|
||||||
|
expect(documentGroup?.results.map((result) => result.path)).toContain(`/admin/documents/${document.id}`);
|
||||||
|
|
||||||
|
// Search by user email (emails are unique nanoid-based, so this is specific).
|
||||||
|
const emailSearch = await callAdminSearch(page, sender.email);
|
||||||
|
|
||||||
|
expect(emailSearch.res.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const userGroup = findGroup(emailSearch.groups, 'user');
|
||||||
|
expect(userGroup).toBeDefined();
|
||||||
|
expect(userGroup?.results[0].path).toBe(`/admin/users/${sender.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][TRPC][SEARCH]: gibberish query returns no groups', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
const { res, groups } = await callAdminSearch(page, 'zzzz-no-such-thing-9x7q');
|
||||||
|
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
expect(groups).toEqual([]);
|
||||||
|
});
|
||||||
@@ -2,10 +2,20 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|||||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
import {
|
||||||
|
DocumentSigningOrder,
|
||||||
|
DocumentStatus,
|
||||||
|
FieldType,
|
||||||
|
RecipientRole,
|
||||||
|
SendStatus,
|
||||||
|
SigningStatus,
|
||||||
|
} from '@documenso/prisma/client';
|
||||||
import { seedBlankDocument, seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
import { seedBlankDocument, seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
import { signSignaturePad } from '../../fixtures/signature';
|
||||||
|
|
||||||
test.describe('Document API', () => {
|
test.describe('Document API', () => {
|
||||||
test('sendDocument: should respect sendCompletionEmails setting', async ({ request }) => {
|
test('sendDocument: should respect sendCompletionEmails setting', async ({ request }) => {
|
||||||
@@ -432,4 +442,194 @@ test.describe('Document API', () => {
|
|||||||
expect(response.ok()).toBeTruthy();
|
expect(response.ok()).toBeTruthy();
|
||||||
expect(response.status()).toBe(200);
|
expect(response.status()).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('sendDocument: should complete document immediately when all recipients are CC', async ({ request }) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
// Create a blank document and get it with envelope items
|
||||||
|
const blankDocument = await seedBlankDocument(user, team.id);
|
||||||
|
const document = await prisma.envelope.findUniqueOrThrow({
|
||||||
|
where: { id: blankDocument.id },
|
||||||
|
include: { envelopeItems: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add two CC recipients without any fields, mirroring the production
|
||||||
|
// state where CC recipients are created pre-signed.
|
||||||
|
for (const email of ['cc1@example.com', 'cc2@example.com']) {
|
||||||
|
await prisma.recipient.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
name: 'Test CC',
|
||||||
|
role: RecipientRole.CC,
|
||||||
|
signingStatus: SigningStatus.SIGNED,
|
||||||
|
sendStatus: SendStatus.SENT,
|
||||||
|
token: nanoid(),
|
||||||
|
envelopeId: document.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token } = await createApiToken({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
tokenName: 'test',
|
||||||
|
expiresIn: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request.post(
|
||||||
|
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.ok()).toBeTruthy();
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
|
||||||
|
// The document seals asynchronously and completes without anyone signing.
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const updatedDocument = await prisma.envelope.findFirstOrThrow({
|
||||||
|
where: { id: document.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedDocument.status;
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(DocumentStatus.COMPLETED);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sendDocument: should not block initial sequential send when CC recipient is first in signing order', async ({
|
||||||
|
request,
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
// Create a blank document and get it with envelope items
|
||||||
|
const blankDocument = await seedBlankDocument(user, team.id);
|
||||||
|
const document = await prisma.envelope.findUniqueOrThrow({
|
||||||
|
where: { id: blankDocument.id },
|
||||||
|
include: { envelopeItems: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.documentMeta.update({
|
||||||
|
where: { id: document.documentMetaId },
|
||||||
|
data: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||||
|
});
|
||||||
|
|
||||||
|
// CC recipient first in the signing order, mirroring the production
|
||||||
|
// state where CC recipients are created pre-signed.
|
||||||
|
await prisma.recipient.create({
|
||||||
|
data: {
|
||||||
|
email: 'cc@example.com',
|
||||||
|
name: 'Test CC',
|
||||||
|
role: RecipientRole.CC,
|
||||||
|
signingOrder: 1,
|
||||||
|
signingStatus: SigningStatus.SIGNED,
|
||||||
|
sendStatus: SendStatus.SENT,
|
||||||
|
token: nanoid(),
|
||||||
|
envelopeId: document.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [signerA, signerB] = await Promise.all(
|
||||||
|
[
|
||||||
|
{ email: 'signer-a@example.com', name: 'Signer A', signingOrder: 2 },
|
||||||
|
{ email: 'signer-b@example.com', name: 'Signer B', signingOrder: 3 },
|
||||||
|
].map(async ({ email, name, signingOrder }) =>
|
||||||
|
prisma.recipient.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
signingOrder,
|
||||||
|
token: nanoid(),
|
||||||
|
envelopeId: document.id,
|
||||||
|
fields: {
|
||||||
|
create: {
|
||||||
|
type: FieldType.SIGNATURE,
|
||||||
|
page: 1,
|
||||||
|
positionX: signingOrder * 10,
|
||||||
|
positionY: 10,
|
||||||
|
width: 5,
|
||||||
|
height: 5,
|
||||||
|
customText: '',
|
||||||
|
inserted: false,
|
||||||
|
envelopeId: document.id,
|
||||||
|
envelopeItemId: document.envelopeItems[0].id,
|
||||||
|
fieldMeta: { type: 'signature', fontSize: 14 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { token } = await createApiToken({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
tokenName: 'test',
|
||||||
|
expiresIn: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request.post(
|
||||||
|
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.ok()).toBeTruthy();
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
|
||||||
|
// The CC recipient at order 1 must not block signer A at order 2.
|
||||||
|
await page.goto(`/sign/${signerA.token}`);
|
||||||
|
await expect(page).not.toHaveURL(`/sign/${signerA.token}/waiting`);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||||
|
|
||||||
|
// Signer B at order 3 must still wait for signer A.
|
||||||
|
await page.goto(`/sign/${signerB.token}`);
|
||||||
|
await expect(page).toHaveURL(`/sign/${signerB.token}/waiting`);
|
||||||
|
|
||||||
|
// Sign as signer A then signer B.
|
||||||
|
for (const signer of [signerA, signerB]) {
|
||||||
|
await page.goto(`/sign/${signer.token}`);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||||
|
await signSignaturePad(page);
|
||||||
|
|
||||||
|
const signerField = await prisma.field.findFirstOrThrow({
|
||||||
|
where: { recipientId: signer.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.locator(`#field-${signerField.id}`).getByRole('button').click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Sign' }).click();
|
||||||
|
await page.waitForURL(`/sign/${signer.token}/complete`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The document completes without any action from the CC recipient.
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const updatedDocument = await prisma.envelope.findFirstOrThrow({
|
||||||
|
where: { id: document.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedDocument.status;
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(DocumentStatus.COMPLETED);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ import type { Organisation, Team, User } from '@prisma/client';
|
|||||||
*
|
*
|
||||||
* --- GLOBAL LIMIT AWARENESS ---
|
* --- GLOBAL LIMIT AWARENESS ---
|
||||||
* apps/remix/server/router.ts applies a GLOBAL per-IP limiter to /api/v1/*:
|
* apps/remix/server/router.ts applies a GLOBAL per-IP limiter to /api/v1/*:
|
||||||
* apiV1RateLimit = 100 requests / 1 minute (action `api.v1`, see rate-limits.ts).
|
* apiV1RateLimit = 1000 requests / 1 minute (action `api.v1`, see rate-limits.ts).
|
||||||
* Every per-org limit/quota configured here is kept FAR below that ceiling (single
|
* Every per-org limit/quota configured here is kept FAR below that ceiling (single
|
||||||
* digits) and the suite runs serially so the shared-IP global bucket is never the
|
* digits) and the suite runs serially so the shared-IP global bucket is never the
|
||||||
* thing that trips. A global-limit 429 is shaped `{ error }` whereas an org-limit
|
* thing that trips. A global-limit 429 is shaped `{ error }` whereas an org-limit
|
||||||
@@ -62,7 +62,7 @@ const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
|||||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v1`;
|
const baseUrl = `${WEBAPP_BASE_URL}/api/v1`;
|
||||||
|
|
||||||
// Run serially: all workers share one IP, and the global /api/v1 limiter is
|
// Run serially: all workers share one IP, and the global /api/v1 limiter is
|
||||||
// per-IP. Serial execution keeps the shared global bucket well under 100/min.
|
// per-IP. Serial execution keeps the shared global bucket well under 1000/min.
|
||||||
test.describe.configure({ mode: 'serial' });
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
// This suite is only meaningful with real rate limiting enabled. CI sets the
|
// This suite is only meaningful with real rate limiting enabled. CI sets the
|
||||||
@@ -125,7 +125,7 @@ const setClaimLimits = async (team: Team, limits: ClaimLimits) => {
|
|||||||
* GLOBAL /api/v1 IP bucket so a fresh scenario starts from zero.
|
* GLOBAL /api/v1 IP bucket so a fresh scenario starts from zero.
|
||||||
*
|
*
|
||||||
* - The org windowed limiter keys its rows `ip:org:<id>`.
|
* - The org windowed limiter keys its rows `ip:org:<id>`.
|
||||||
* - The GLOBAL limiter (apps/remix/server/router.ts -> apiV1RateLimit, 100/min
|
* - The GLOBAL limiter (apps/remix/server/router.ts -> apiV1RateLimit, 1000/min
|
||||||
* per IP, action `api.v1`) is shared by EVERY v1 request from this test client.
|
* per IP, action `api.v1`) is shared by EVERY v1 request from this test client.
|
||||||
* Across the suite (and especially across repeated local runs within the same
|
* Across the suite (and especially across repeated local runs within the same
|
||||||
* minute) that shared bucket would otherwise fill up and trip BEFORE the org
|
* minute) that shared bucket would otherwise fill up and trip BEFORE the org
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
|
import {
|
||||||
|
DocumentStatus,
|
||||||
|
DocumentVisibility,
|
||||||
|
RecipientRole,
|
||||||
|
SigningStatus,
|
||||||
|
TeamMemberRole,
|
||||||
|
} from '@documenso/prisma/client';
|
||||||
import {
|
import {
|
||||||
seedBlankDocument,
|
seedBlankDocument,
|
||||||
seedCompletedDocument,
|
seedCompletedDocument,
|
||||||
@@ -1560,3 +1566,307 @@ test.describe('Find Documents API - Adversarial: Cross-Team templateId', () => {
|
|||||||
expect(ownTemplate!.data[0].title).toBe('TeamA Doc from Template');
|
expect(ownTemplate!.data[0].title).toBe('TeamA Doc from Template');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('Find Documents API - Expired Recipient Filter', () => {
|
||||||
|
const PAST = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
const FUTURE = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
test('hasExpiredRecipients=true returns only docs with an expired, unsigned, non-CC recipient', async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
const { user: recipient } = await seedUser();
|
||||||
|
|
||||||
|
const { token } = await createApiToken({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
tokenName: 'expired-token',
|
||||||
|
expiresIn: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const expiredDoc = await seedPendingDocument(user, team.id, [recipient], {
|
||||||
|
createDocumentOptions: { title: 'Expired Recipient Doc' },
|
||||||
|
});
|
||||||
|
await prisma.recipient.updateMany({
|
||||||
|
where: { envelopeId: expiredDoc.id },
|
||||||
|
data: { expiresAt: PAST },
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeDoc = await seedPendingDocument(user, team.id, [recipient], {
|
||||||
|
createDocumentOptions: { title: 'Active Recipient Doc' },
|
||||||
|
});
|
||||||
|
await prisma.recipient.updateMany({
|
||||||
|
where: { envelopeId: activeDoc.id },
|
||||||
|
data: { expiresAt: FUTURE },
|
||||||
|
});
|
||||||
|
|
||||||
|
await seedPendingDocument(user, team.id, [recipient], {
|
||||||
|
createDocumentOptions: { title: 'No Expiry Doc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { json } = await findDocuments(request, token, { hasExpiredRecipients: 'true' });
|
||||||
|
const titles = json!.data.map((d) => d.title);
|
||||||
|
expect(titles).toContain('Expired Recipient Doc');
|
||||||
|
expect(titles).not.toContain('Active Recipient Doc');
|
||||||
|
expect(titles).not.toContain('No Expiry Doc');
|
||||||
|
expect(json!.count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasExpiredRecipients=false (and omitted) does not filter by expiry', async ({ request }) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
const { user: recipient } = await seedUser();
|
||||||
|
|
||||||
|
const { token } = await createApiToken({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
tokenName: 'expired-false-token',
|
||||||
|
expiresIn: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const expiredDoc = await seedPendingDocument(user, team.id, [recipient], {
|
||||||
|
createDocumentOptions: { title: 'Expired Doc' },
|
||||||
|
});
|
||||||
|
await prisma.recipient.updateMany({
|
||||||
|
where: { envelopeId: expiredDoc.id },
|
||||||
|
data: { expiresAt: PAST },
|
||||||
|
});
|
||||||
|
|
||||||
|
await seedPendingDocument(user, team.id, [recipient], {
|
||||||
|
createDocumentOptions: { title: 'Active Doc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// "false" must NOT be coerced to true — both docs should be returned.
|
||||||
|
const { json: falseJson } = await findDocuments(request, token, { hasExpiredRecipients: 'false' });
|
||||||
|
expect(falseJson!.count).toBe(2);
|
||||||
|
|
||||||
|
const { json: omittedJson } = await findDocuments(request, token);
|
||||||
|
expect(omittedJson!.count).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('excludes signed and CC recipients from the expired filter', async ({ request }) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
const { user: recipient } = await seedUser();
|
||||||
|
|
||||||
|
const { token } = await createApiToken({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
tokenName: 'expired-exclude-token',
|
||||||
|
expiresIn: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const signedDoc = await seedPendingDocument(user, team.id, [recipient], {
|
||||||
|
createDocumentOptions: { title: 'Expired but Signed' },
|
||||||
|
});
|
||||||
|
await prisma.recipient.updateMany({
|
||||||
|
where: { envelopeId: signedDoc.id },
|
||||||
|
data: { expiresAt: PAST, signingStatus: SigningStatus.SIGNED },
|
||||||
|
});
|
||||||
|
|
||||||
|
const ccDoc = await seedPendingDocument(user, team.id, [recipient], {
|
||||||
|
createDocumentOptions: { title: 'Expired but CC' },
|
||||||
|
});
|
||||||
|
await prisma.recipient.updateMany({
|
||||||
|
where: { envelopeId: ccDoc.id },
|
||||||
|
data: { expiresAt: PAST, role: RecipientRole.CC },
|
||||||
|
});
|
||||||
|
|
||||||
|
const validDoc = await seedPendingDocument(user, team.id, [recipient], {
|
||||||
|
createDocumentOptions: { title: 'Expired Unsigned Signer' },
|
||||||
|
});
|
||||||
|
await prisma.recipient.updateMany({
|
||||||
|
where: { envelopeId: validDoc.id },
|
||||||
|
data: { expiresAt: PAST },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { json } = await findDocuments(request, token, { hasExpiredRecipients: 'true' });
|
||||||
|
const titles = json!.data.map((d) => d.title);
|
||||||
|
expect(titles).toContain('Expired Unsigned Signer');
|
||||||
|
expect(titles).not.toContain('Expired but Signed');
|
||||||
|
expect(titles).not.toContain('Expired but CC');
|
||||||
|
expect(json!.count).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Adversarial: Expired Recipient Filter cross-tenant isolation ────────────
|
||||||
|
// The expired filter adds an EXISTS subquery over Recipient. These tests ensure
|
||||||
|
// that predicate never widens visibility past the caller's team/access scope.
|
||||||
|
|
||||||
|
test.describe('Find Documents API - Adversarial: Cross-Team Expired Recipient Filter', () => {
|
||||||
|
const PAST = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
test('token scoped to team A must NOT see team B docs with expired recipients', async ({ request }) => {
|
||||||
|
const { user: userA, team: teamA } = await seedUser();
|
||||||
|
const { user: userB, team: teamB } = await seedUser();
|
||||||
|
const { user: recipient } = await seedUser();
|
||||||
|
|
||||||
|
const { token: tokenA } = await createApiToken({
|
||||||
|
userId: userA.id,
|
||||||
|
teamId: teamA.id,
|
||||||
|
tokenName: 'teamA-expired-token',
|
||||||
|
expiresIn: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Team A: one expired doc the caller is legitimately allowed to see.
|
||||||
|
const teamADoc = await seedPendingDocument(userA, teamA.id, [recipient], {
|
||||||
|
createDocumentOptions: { title: 'TeamA Expired Doc' },
|
||||||
|
});
|
||||||
|
await prisma.recipient.updateMany({
|
||||||
|
where: { envelopeId: teamADoc.id },
|
||||||
|
data: { expiresAt: PAST },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Team B: an expired doc that must remain invisible to team A's token.
|
||||||
|
const teamBDoc = await seedPendingDocument(userB, teamB.id, [recipient], {
|
||||||
|
createDocumentOptions: { title: 'TeamB Expired Doc' },
|
||||||
|
});
|
||||||
|
await prisma.recipient.updateMany({
|
||||||
|
where: { envelopeId: teamBDoc.id },
|
||||||
|
data: { expiresAt: PAST },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { json } = await findDocuments(request, tokenA, { hasExpiredRecipients: 'true' });
|
||||||
|
const titles = json!.data.map((d) => d.title);
|
||||||
|
expect(titles).toContain('TeamA Expired Doc');
|
||||||
|
expect(titles).not.toContain('TeamB Expired Doc');
|
||||||
|
expect(json!.count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shared recipient email across teams does not leak the other team expired docs', async ({ request }) => {
|
||||||
|
// A recipient with the SAME email is on expired docs in both teams. The
|
||||||
|
// filter must still scope strictly to the token's team.
|
||||||
|
const { user: userA, team: teamA } = await seedUser();
|
||||||
|
const { user: userB, team: teamB } = await seedUser();
|
||||||
|
const { user: sharedRecipient } = await seedUser();
|
||||||
|
|
||||||
|
const { token: tokenB } = await createApiToken({
|
||||||
|
userId: userB.id,
|
||||||
|
teamId: teamB.id,
|
||||||
|
tokenName: 'teamB-expired-token',
|
||||||
|
expiresIn: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamADoc = await seedPendingDocument(userA, teamA.id, [sharedRecipient], {
|
||||||
|
createDocumentOptions: { title: 'TeamA Shared-Recipient Expired' },
|
||||||
|
});
|
||||||
|
await prisma.recipient.updateMany({
|
||||||
|
where: { envelopeId: teamADoc.id },
|
||||||
|
data: { expiresAt: PAST },
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamBDoc = await seedPendingDocument(userB, teamB.id, [sharedRecipient], {
|
||||||
|
createDocumentOptions: { title: 'TeamB Shared-Recipient Expired' },
|
||||||
|
});
|
||||||
|
await prisma.recipient.updateMany({
|
||||||
|
where: { envelopeId: teamBDoc.id },
|
||||||
|
data: { expiresAt: PAST },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { json } = await findDocuments(request, tokenB, { hasExpiredRecipients: 'true' });
|
||||||
|
const titles = json!.data.map((d) => d.title);
|
||||||
|
expect(titles).toContain('TeamB Shared-Recipient Expired');
|
||||||
|
expect(titles).not.toContain('TeamA Shared-Recipient Expired');
|
||||||
|
expect(json!.count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('x-team-id spoofing with status=EXPIRED is rejected for a non-member', async ({ page }) => {
|
||||||
|
const { team: teamA, owner: ownerA } = await seedTeam();
|
||||||
|
const { team: teamB, owner: ownerB } = await seedTeam();
|
||||||
|
const { user: recipient } = await seedUser();
|
||||||
|
|
||||||
|
const teamADoc = await seedPendingDocument(ownerA, teamA.id, [recipient], {
|
||||||
|
createDocumentOptions: { title: 'TeamA Expired Secret' },
|
||||||
|
});
|
||||||
|
await prisma.recipient.updateMany({
|
||||||
|
where: { envelopeId: teamADoc.id },
|
||||||
|
data: { expiresAt: PAST },
|
||||||
|
});
|
||||||
|
|
||||||
|
// ownerB is NOT a member of teamA.
|
||||||
|
await apiSignin({ page, email: ownerB.email });
|
||||||
|
|
||||||
|
const res = await trpcQuery(page, 'document.findDocumentsInternal', teamA.id, {
|
||||||
|
status: 'EXPIRED',
|
||||||
|
page: 1,
|
||||||
|
perPage: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok()).toBeFalsy();
|
||||||
|
expect(res.status()).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EXPIRED pseudo-status via session only returns the caller team expired docs (positive control)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const { team: teamA, owner: ownerA } = await seedTeam();
|
||||||
|
const { team: teamB, owner: ownerB } = await seedTeam();
|
||||||
|
const { user: recipient } = await seedUser();
|
||||||
|
|
||||||
|
const teamADoc = await seedPendingDocument(ownerA, teamA.id, [recipient], {
|
||||||
|
createDocumentOptions: { title: 'TeamA Expired Visible' },
|
||||||
|
});
|
||||||
|
await prisma.recipient.updateMany({
|
||||||
|
where: { envelopeId: teamADoc.id },
|
||||||
|
data: { expiresAt: PAST },
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamBDoc = await seedPendingDocument(ownerB, teamB.id, [recipient], {
|
||||||
|
createDocumentOptions: { title: 'TeamB Expired Hidden' },
|
||||||
|
});
|
||||||
|
await prisma.recipient.updateMany({
|
||||||
|
where: { envelopeId: teamBDoc.id },
|
||||||
|
data: { expiresAt: PAST },
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({ page, email: ownerA.email });
|
||||||
|
|
||||||
|
const res = await trpcQuery(page, 'document.findDocumentsInternal', teamA.id, {
|
||||||
|
status: 'EXPIRED',
|
||||||
|
page: 1,
|
||||||
|
perPage: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const data = await res.json();
|
||||||
|
const docs = data.result.data.json.data;
|
||||||
|
const titles = docs.map((d: { title: string }) => d.title);
|
||||||
|
expect(titles).toContain('TeamA Expired Visible');
|
||||||
|
expect(titles).not.toContain('TeamB Expired Hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EXPIRED stats count is scoped to the caller team and excludes other-team expired docs', async ({ page }) => {
|
||||||
|
const { team: teamA, owner: ownerA } = await seedTeam();
|
||||||
|
const { team: teamB, owner: ownerB } = await seedTeam();
|
||||||
|
const { user: recipient } = await seedUser();
|
||||||
|
|
||||||
|
// One expired doc in team A.
|
||||||
|
const teamADoc = await seedPendingDocument(ownerA, teamA.id, [recipient], {
|
||||||
|
createDocumentOptions: { title: 'TeamA Expired For Stats' },
|
||||||
|
});
|
||||||
|
await prisma.recipient.updateMany({
|
||||||
|
where: { envelopeId: teamADoc.id },
|
||||||
|
data: { expiresAt: PAST },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Two expired docs in team B — must NOT bleed into team A's EXPIRED count.
|
||||||
|
for (const title of ['TeamB Expired For Stats 1', 'TeamB Expired For Stats 2']) {
|
||||||
|
const doc = await seedPendingDocument(ownerB, teamB.id, [recipient], {
|
||||||
|
createDocumentOptions: { title },
|
||||||
|
});
|
||||||
|
await prisma.recipient.updateMany({
|
||||||
|
where: { envelopeId: doc.id },
|
||||||
|
data: { expiresAt: PAST },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiSignin({ page, email: ownerA.email });
|
||||||
|
|
||||||
|
const res = await trpcQuery(page, 'document.findDocumentsInternal', teamA.id, {
|
||||||
|
page: 1,
|
||||||
|
perPage: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.result.data.json.stats.EXPIRED).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1055,3 +1055,120 @@ test.describe('Find Envelopes API - Cross-User Isolation', () => {
|
|||||||
expect(titles).not.toContain('Member Org Team Env');
|
expect(titles).not.toContain('Member Org Team Env');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('Find Envelopes API - Expired Recipient Filter', () => {
|
||||||
|
test('hasExpiredRecipients=true returns only envelopes with an expired, unsigned recipient', async ({ request }) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
const { user: recipient } = await seedUser();
|
||||||
|
|
||||||
|
const { token } = await createApiToken({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
tokenName: 'env-expired-token',
|
||||||
|
expiresIn: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const expiredEnvelope = await seedPendingDocument(user, team.id, [recipient], {
|
||||||
|
createDocumentOptions: { title: 'Expired Envelope' },
|
||||||
|
});
|
||||||
|
await prisma.recipient.updateMany({
|
||||||
|
where: { envelopeId: expiredEnvelope.id },
|
||||||
|
data: { expiresAt: new Date(Date.now() - 24 * 60 * 60 * 1000) },
|
||||||
|
});
|
||||||
|
|
||||||
|
await seedPendingDocument(user, team.id, [recipient], {
|
||||||
|
createDocumentOptions: { title: 'Active Envelope' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { json } = await findEnvelopes(request, token, {
|
||||||
|
type: EnvelopeType.DOCUMENT,
|
||||||
|
hasExpiredRecipients: 'true',
|
||||||
|
});
|
||||||
|
const titles = json!.data.map((d) => d.title);
|
||||||
|
expect(titles).toContain('Expired Envelope');
|
||||||
|
expect(titles).not.toContain('Active Envelope');
|
||||||
|
expect(json!.count).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Adversarial: Expired Recipient Filter cross-tenant isolation ────────────
|
||||||
|
|
||||||
|
test.describe('Find Envelopes API - Adversarial: Cross-Team Expired Recipient Filter', () => {
|
||||||
|
const PAST = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
test('token scoped to team A must NOT see team B envelopes with expired recipients', async ({ request }) => {
|
||||||
|
const { user: userA, team: teamA } = await seedUser();
|
||||||
|
const { user: userB, team: teamB } = await seedUser();
|
||||||
|
const { user: recipient } = await seedUser();
|
||||||
|
|
||||||
|
const { token: tokenA } = await createApiToken({
|
||||||
|
userId: userA.id,
|
||||||
|
teamId: teamA.id,
|
||||||
|
tokenName: 'env-teamA-expired-token',
|
||||||
|
expiresIn: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamAEnvelope = await seedPendingDocument(userA, teamA.id, [recipient], {
|
||||||
|
createDocumentOptions: { title: 'TeamA Expired Envelope' },
|
||||||
|
});
|
||||||
|
await prisma.recipient.updateMany({
|
||||||
|
where: { envelopeId: teamAEnvelope.id },
|
||||||
|
data: { expiresAt: PAST },
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamBEnvelope = await seedPendingDocument(userB, teamB.id, [recipient], {
|
||||||
|
createDocumentOptions: { title: 'TeamB Expired Envelope' },
|
||||||
|
});
|
||||||
|
await prisma.recipient.updateMany({
|
||||||
|
where: { envelopeId: teamBEnvelope.id },
|
||||||
|
data: { expiresAt: PAST },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { json } = await findEnvelopes(request, tokenA, {
|
||||||
|
type: EnvelopeType.DOCUMENT,
|
||||||
|
hasExpiredRecipients: 'true',
|
||||||
|
});
|
||||||
|
const titles = json!.data.map((d) => d.title);
|
||||||
|
expect(titles).toContain('TeamA Expired Envelope');
|
||||||
|
expect(titles).not.toContain('TeamB Expired Envelope');
|
||||||
|
expect(json!.count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shared recipient email across teams does not leak the other team expired envelopes', async ({ request }) => {
|
||||||
|
const { user: userA, team: teamA } = await seedUser();
|
||||||
|
const { user: userB, team: teamB } = await seedUser();
|
||||||
|
const { user: sharedRecipient } = await seedUser();
|
||||||
|
|
||||||
|
const { token: tokenB } = await createApiToken({
|
||||||
|
userId: userB.id,
|
||||||
|
teamId: teamB.id,
|
||||||
|
tokenName: 'env-teamB-expired-token',
|
||||||
|
expiresIn: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamAEnvelope = await seedPendingDocument(userA, teamA.id, [sharedRecipient], {
|
||||||
|
createDocumentOptions: { title: 'TeamA Shared Expired Envelope' },
|
||||||
|
});
|
||||||
|
await prisma.recipient.updateMany({
|
||||||
|
where: { envelopeId: teamAEnvelope.id },
|
||||||
|
data: { expiresAt: PAST },
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamBEnvelope = await seedPendingDocument(userB, teamB.id, [sharedRecipient], {
|
||||||
|
createDocumentOptions: { title: 'TeamB Shared Expired Envelope' },
|
||||||
|
});
|
||||||
|
await prisma.recipient.updateMany({
|
||||||
|
where: { envelopeId: teamBEnvelope.id },
|
||||||
|
data: { expiresAt: PAST },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { json } = await findEnvelopes(request, tokenB, {
|
||||||
|
type: EnvelopeType.DOCUMENT,
|
||||||
|
hasExpiredRecipients: 'true',
|
||||||
|
});
|
||||||
|
const titles = json!.data.map((d) => d.title);
|
||||||
|
expect(titles).toContain('TeamB Shared Expired Envelope');
|
||||||
|
expect(titles).not.toContain('TeamA Shared Expired Envelope');
|
||||||
|
expect(json!.count).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import type { Organisation, Team, User } from '@prisma/client';
|
|||||||
*
|
*
|
||||||
* --- GLOBAL LIMIT AWARENESS ---
|
* --- GLOBAL LIMIT AWARENESS ---
|
||||||
* apps/remix/server/router.ts applies a GLOBAL per-IP limiter to /api/v2/*:
|
* apps/remix/server/router.ts applies a GLOBAL per-IP limiter to /api/v2/*:
|
||||||
* apiV2RateLimit = 100 requests / 1 minute (see rate-limits.ts).
|
* apiV2RateLimit = 1000 requests / 1 minute (see rate-limits.ts).
|
||||||
* Every per-org limit/quota configured here is kept FAR below that ceiling (single
|
* Every per-org limit/quota configured here is kept FAR below that ceiling (single
|
||||||
* digits) and the suite runs serially so the shared-IP global bucket is never the
|
* digits) and the suite runs serially so the shared-IP global bucket is never the
|
||||||
* thing that trips. A global-limit 429 is shaped `{ error }` whereas an org-limit
|
* thing that trips. A global-limit 429 is shaped `{ error }` whereas an org-limit
|
||||||
@@ -49,7 +49,7 @@ const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
|||||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||||
|
|
||||||
// Run serially: all workers share one IP, and the global /api/v2 limiter is
|
// Run serially: all workers share one IP, and the global /api/v2 limiter is
|
||||||
// per-IP. Serial execution keeps the shared global bucket well under 100/min.
|
// per-IP. Serial execution keeps the shared global bucket well under 1000/min.
|
||||||
test.describe.configure({ mode: 'serial' });
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
// This suite is only meaningful with real rate limiting enabled. CI sets the
|
// This suite is only meaningful with real rate limiting enabled. CI sets the
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { seedUser } from '@documenso/prisma/seed/users';
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
import { apiSignin } from '../fixtures/authentication';
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
import { openCommandMenu } from '../fixtures/command-menu';
|
||||||
|
|
||||||
|
const COMMAND_MENU_PLACEHOLDER = 'Type a command or search...';
|
||||||
|
|
||||||
test('[COMMAND_MENU]: should see sent documents', async ({ page }) => {
|
test('[COMMAND_MENU]: should see sent documents', async ({ page }) => {
|
||||||
const { user, team } = await seedUser();
|
const { user, team } = await seedUser();
|
||||||
@@ -14,9 +17,9 @@ test('[COMMAND_MENU]: should see sent documents', async ({ page }) => {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.keyboard.press('Meta+K');
|
await openCommandMenu(page, COMMAND_MENU_PLACEHOLDER);
|
||||||
|
|
||||||
await page.getByPlaceholder('Type a command or search...').first().fill(document.title);
|
await page.getByPlaceholder(COMMAND_MENU_PLACEHOLDER).first().fill(document.title);
|
||||||
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
|
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -30,9 +33,9 @@ test('[COMMAND_MENU]: should see received documents', async ({ page }) => {
|
|||||||
email: recipient.email,
|
email: recipient.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.keyboard.press('Meta+K');
|
await openCommandMenu(page, COMMAND_MENU_PLACEHOLDER);
|
||||||
|
|
||||||
await page.getByPlaceholder('Type a command or search...').first().fill(document.title);
|
await page.getByPlaceholder(COMMAND_MENU_PLACEHOLDER).first().fill(document.title);
|
||||||
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
|
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,8 +49,8 @@ test('[COMMAND_MENU]: should be able to search by recipient', async ({ page }) =
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.keyboard.press('Meta+K');
|
await openCommandMenu(page, COMMAND_MENU_PLACEHOLDER);
|
||||||
|
|
||||||
await page.getByPlaceholder('Type a command or search...').first().fill(recipient.email);
|
await page.getByPlaceholder(COMMAND_MENU_PLACEHOLDER).first().fill(recipient.email);
|
||||||
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
|
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ import { prisma } from '@documenso/prisma';
|
|||||||
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import { DocumentSigningOrder, DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
|
import {
|
||||||
|
DocumentSigningOrder,
|
||||||
|
DocumentStatus,
|
||||||
|
FieldType,
|
||||||
|
RecipientRole,
|
||||||
|
SendStatus,
|
||||||
|
SigningStatus,
|
||||||
|
} from '@prisma/client';
|
||||||
|
|
||||||
import { signDirectSignaturePad, signSignaturePad } from '../fixtures/signature';
|
import { signDirectSignaturePad, signSignaturePad } from '../fixtures/signature';
|
||||||
|
|
||||||
@@ -370,3 +377,221 @@ test('[NEXT_RECIPIENT_DICTATION]: should allow assistant to dictate next signer'
|
|||||||
expect(thirdRecipient.role).toBe(RecipientRole.SIGNER);
|
expect(thirdRecipient.role).toBe(RecipientRole.SIGNER);
|
||||||
}).toPass();
|
}).toPass();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('[NEXT_RECIPIENT_DICTATION]: should skip CC recipient when dictating next signer', async ({ page }) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
const { user: firstSigner } = await seedUser();
|
||||||
|
const { user: ccUser } = await seedUser();
|
||||||
|
const { user: secondSigner } = await seedUser();
|
||||||
|
|
||||||
|
const { recipients, document } = await seedPendingDocumentWithFullFields({
|
||||||
|
owner: user,
|
||||||
|
teamId: team.id,
|
||||||
|
recipients: [firstSigner, ccUser, secondSigner],
|
||||||
|
recipientsCreateOptions: [
|
||||||
|
{ signingOrder: 1 },
|
||||||
|
{
|
||||||
|
// CC recipients are created pre-signed, mirroring production behaviour.
|
||||||
|
signingOrder: 2,
|
||||||
|
role: RecipientRole.CC,
|
||||||
|
signingStatus: SigningStatus.SIGNED,
|
||||||
|
sendStatus: SendStatus.SENT,
|
||||||
|
},
|
||||||
|
{ signingOrder: 3 },
|
||||||
|
],
|
||||||
|
updateDocumentOptions: {
|
||||||
|
documentMeta: {
|
||||||
|
upsert: {
|
||||||
|
create: {
|
||||||
|
allowDictateNextSigner: true,
|
||||||
|
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
allowDictateNextSigner: true,
|
||||||
|
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstRecipient = recipients.find((r) => r.email === firstSigner.email);
|
||||||
|
const ccRecipient = recipients.find((r) => r.email === ccUser.email);
|
||||||
|
|
||||||
|
if (!firstRecipient || !ccRecipient) {
|
||||||
|
throw new Error('Recipients not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// CC recipients cannot have fields.
|
||||||
|
await prisma.field.deleteMany({
|
||||||
|
where: {
|
||||||
|
recipientId: ccRecipient.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { token, fields } = firstRecipient;
|
||||||
|
|
||||||
|
const signUrl = `/sign/${token}`;
|
||||||
|
|
||||||
|
await page.goto(signUrl);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||||
|
|
||||||
|
await signSignaturePad(page);
|
||||||
|
|
||||||
|
// Fill in all fields
|
||||||
|
for (const field of fields) {
|
||||||
|
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||||
|
|
||||||
|
if (field.type === FieldType.TEXT) {
|
||||||
|
await page.locator('#custom-text').fill('TEXT');
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete signing and verify the offered next recipient
|
||||||
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
await expect(page.getByText('Next Recipient Name')).toBeVisible();
|
||||||
|
|
||||||
|
// The dictation dialog must offer the second signer, not the CC recipient.
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog.getByLabel('Name')).toHaveValue(secondSigner.name ?? '');
|
||||||
|
await expect(dialog.getByLabel('Email')).toHaveValue(secondSigner.email);
|
||||||
|
|
||||||
|
// Submit and verify completion
|
||||||
|
await page.getByRole('button', { name: 'Sign' }).click();
|
||||||
|
await page.waitForURL(`${signUrl}/complete`);
|
||||||
|
|
||||||
|
// Verify document and recipient states
|
||||||
|
const updatedDocument = await prisma.envelope.findUniqueOrThrow({
|
||||||
|
where: { id: document.id },
|
||||||
|
include: {
|
||||||
|
recipients: {
|
||||||
|
orderBy: { signingOrder: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Document should still be pending as the second signer has not signed
|
||||||
|
expect(updatedDocument.status).toBe(DocumentStatus.PENDING);
|
||||||
|
|
||||||
|
// The CC recipient must remain untouched
|
||||||
|
const updatedCcRecipient = updatedDocument.recipients[1];
|
||||||
|
expect(updatedCcRecipient.email).toBe(ccUser.email);
|
||||||
|
expect(updatedCcRecipient.role).toBe(RecipientRole.CC);
|
||||||
|
expect(updatedCcRecipient.signingStatus).toBe(SigningStatus.SIGNED);
|
||||||
|
|
||||||
|
// The second signer must remain the next pending recipient
|
||||||
|
const updatedSecondRecipient = updatedDocument.recipients[2];
|
||||||
|
expect(updatedSecondRecipient.email).toBe(secondSigner.email);
|
||||||
|
expect(updatedSecondRecipient.signingOrder).toBe(3);
|
||||||
|
expect(updatedSecondRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[NEXT_RECIPIENT_DICTATION]: should not offer dictation when CC recipient is last', async ({ page }) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
const { user: firstSigner } = await seedUser();
|
||||||
|
const { user: secondSigner } = await seedUser();
|
||||||
|
const { user: ccUser } = await seedUser();
|
||||||
|
|
||||||
|
const { recipients, document } = await seedPendingDocumentWithFullFields({
|
||||||
|
owner: user,
|
||||||
|
teamId: team.id,
|
||||||
|
recipients: [firstSigner, secondSigner, ccUser],
|
||||||
|
recipientsCreateOptions: [
|
||||||
|
{ signingOrder: 1 },
|
||||||
|
{ signingOrder: 2 },
|
||||||
|
{
|
||||||
|
// CC recipients are created pre-signed, mirroring production behaviour.
|
||||||
|
signingOrder: 3,
|
||||||
|
role: RecipientRole.CC,
|
||||||
|
signingStatus: SigningStatus.SIGNED,
|
||||||
|
sendStatus: SendStatus.SENT,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
updateDocumentOptions: {
|
||||||
|
documentMeta: {
|
||||||
|
upsert: {
|
||||||
|
create: {
|
||||||
|
allowDictateNextSigner: true,
|
||||||
|
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
allowDictateNextSigner: true,
|
||||||
|
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstRecipient = recipients.find((r) => r.email === firstSigner.email);
|
||||||
|
const secondRecipient = recipients.find((r) => r.email === secondSigner.email);
|
||||||
|
const ccRecipient = recipients.find((r) => r.email === ccUser.email);
|
||||||
|
|
||||||
|
if (!firstRecipient || !secondRecipient || !ccRecipient) {
|
||||||
|
throw new Error('Recipients not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// CC recipients cannot have fields.
|
||||||
|
await prisma.field.deleteMany({
|
||||||
|
where: {
|
||||||
|
recipientId: ccRecipient.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sign as both signers in order.
|
||||||
|
for (const recipient of [firstRecipient, secondRecipient]) {
|
||||||
|
const { token, fields } = recipient;
|
||||||
|
|
||||||
|
const signUrl = `/sign/${token}`;
|
||||||
|
|
||||||
|
await page.goto(signUrl);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||||
|
|
||||||
|
await signSignaturePad(page);
|
||||||
|
|
||||||
|
// Fill in all fields
|
||||||
|
for (const field of fields) {
|
||||||
|
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||||
|
|
||||||
|
if (field.type === FieldType.TEXT) {
|
||||||
|
await page.locator('#custom-text').fill('TEXT');
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete signing
|
||||||
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
if (recipient.id === secondRecipient.id) {
|
||||||
|
// The last actionable signer must not be offered the CC recipient.
|
||||||
|
await expect(page.getByText('Next Recipient Name')).not.toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit and verify completion
|
||||||
|
await page.getByRole('button', { name: 'Sign' }).click();
|
||||||
|
await page.waitForURL(`${signUrl}/complete`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The document completes without any action from the CC recipient.
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const finalDocument = await prisma.envelope.findUniqueOrThrow({
|
||||||
|
where: { id: document.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return finalDocument.status;
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(DocumentStatus.COMPLETED);
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ import { prisma } from '@documenso/prisma';
|
|||||||
import { seedBlankDocument, seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
import { seedBlankDocument, seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import { DocumentSigningOrder, DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
|
import {
|
||||||
|
DocumentSigningOrder,
|
||||||
|
DocumentStatus,
|
||||||
|
FieldType,
|
||||||
|
RecipientRole,
|
||||||
|
SendStatus,
|
||||||
|
SigningStatus,
|
||||||
|
} from '@prisma/client';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { apiSignin } from '../fixtures/authentication';
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
@@ -225,15 +232,20 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
|||||||
await page.getByLabel('Receives copy').click();
|
await page.getByLabel('Receives copy').click();
|
||||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
|
|
||||||
await page.getByLabel('Email').nth(2).fill('user3@example.com');
|
// CC recipients are kept last, so new rows are inserted above the CC row.
|
||||||
await page.getByLabel('Name').nth(2).fill('User 3');
|
await expect(page.getByLabel('Email')).toHaveCount(3);
|
||||||
await page.getByRole('combobox').nth(2).click();
|
|
||||||
|
await page.getByLabel('Email').nth(1).fill('user3@example.com');
|
||||||
|
await page.getByLabel('Name').nth(1).fill('User 3');
|
||||||
|
await page.getByRole('combobox').nth(1).click();
|
||||||
await page.getByLabel('Needs to approve').click();
|
await page.getByLabel('Needs to approve').click();
|
||||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
|
|
||||||
await page.getByLabel('Email').nth(3).fill('user4@example.com');
|
await expect(page.getByLabel('Email')).toHaveCount(4);
|
||||||
await page.getByLabel('Name').nth(3).fill('User 4');
|
|
||||||
await page.getByRole('combobox').nth(3).click();
|
await page.getByLabel('Email').nth(2).fill('user4@example.com');
|
||||||
|
await page.getByLabel('Name').nth(2).fill('User 4');
|
||||||
|
await page.getByRole('combobox').nth(2).click();
|
||||||
await page.getByLabel('Needs to view').click();
|
await page.getByLabel('Needs to view').click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
@@ -661,3 +673,182 @@ test('[DOCUMENT_FLOW]: should prevent out-of-order signing in sequential mode',
|
|||||||
await expect(page).not.toHaveURL(`/sign/${activeRecipient?.token}/waiting`);
|
await expect(page).not.toHaveURL(`/sign/${activeRecipient?.token}/waiting`);
|
||||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('[DOCUMENT_FLOW]: should skip CC recipients in sequential signing order', async ({ page }) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
const { document, recipients } = await seedPendingDocumentWithFullFields({
|
||||||
|
teamId: team.id,
|
||||||
|
owner: user,
|
||||||
|
recipients: ['signer1@example.com', 'cc@example.com', 'signer2@example.com'],
|
||||||
|
fields: [FieldType.SIGNATURE],
|
||||||
|
recipientsCreateOptions: [
|
||||||
|
{ signingOrder: 1 },
|
||||||
|
{
|
||||||
|
// CC recipients are created pre-signed, mirroring production behaviour.
|
||||||
|
signingOrder: 2,
|
||||||
|
role: RecipientRole.CC,
|
||||||
|
signingStatus: SigningStatus.SIGNED,
|
||||||
|
sendStatus: SendStatus.SENT,
|
||||||
|
},
|
||||||
|
{ signingOrder: 3 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.documentMeta.update({
|
||||||
|
where: {
|
||||||
|
id: document.documentMetaId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstSigner = recipients.find((r) => r.email === 'signer1@example.com');
|
||||||
|
const ccRecipient = recipients.find((r) => r.email === 'cc@example.com');
|
||||||
|
const lastSigner = recipients.find((r) => r.email === 'signer2@example.com');
|
||||||
|
|
||||||
|
// CC recipients cannot have fields.
|
||||||
|
await prisma.field.deleteMany({
|
||||||
|
where: {
|
||||||
|
recipientId: ccRecipient?.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sequential order is enforced: the last signer must wait while the first signer is pending.
|
||||||
|
await page.goto(`/sign/${lastSigner?.token}`);
|
||||||
|
await expect(page).toHaveURL(`/sign/${lastSigner?.token}/waiting`);
|
||||||
|
|
||||||
|
// Sign as the first signer.
|
||||||
|
await page.goto(`/sign/${firstSigner?.token}`);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||||
|
await signSignaturePad(page);
|
||||||
|
|
||||||
|
const firstSignerField = await prisma.field.findFirstOrThrow({
|
||||||
|
where: { recipientId: firstSigner?.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.locator(`#field-${firstSignerField.id}`).getByRole('button').click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Sign' }).click();
|
||||||
|
await page.waitForURL(`/sign/${firstSigner?.token}/complete`);
|
||||||
|
|
||||||
|
// The CC recipient at order 2 must not block the last signer at order 3.
|
||||||
|
await page.goto(`/sign/${lastSigner?.token}`);
|
||||||
|
await expect(page).not.toHaveURL(`/sign/${lastSigner?.token}/waiting`);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||||
|
|
||||||
|
await signSignaturePad(page);
|
||||||
|
|
||||||
|
const lastSignerField = await prisma.field.findFirstOrThrow({
|
||||||
|
where: { recipientId: lastSigner?.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.locator(`#field-${lastSignerField.id}`).getByRole('button').click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Sign' }).click();
|
||||||
|
await page.waitForURL(`/sign/${lastSigner?.token}/complete`);
|
||||||
|
|
||||||
|
// The document completes without any action from the CC recipient.
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const finalDocument = await prisma.envelope.findFirstOrThrow({
|
||||||
|
where: { id: document.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return finalDocument.status;
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(DocumentStatus.COMPLETED);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[DOCUMENT_FLOW]: should skip unsigned CC recipients in sequential signing order', async ({ page }) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
const { document, recipients } = await seedPendingDocumentWithFullFields({
|
||||||
|
teamId: team.id,
|
||||||
|
owner: user,
|
||||||
|
recipients: ['signer1@example.com', 'cc@example.com', 'signer2@example.com'],
|
||||||
|
fields: [FieldType.SIGNATURE],
|
||||||
|
recipientsCreateOptions: [
|
||||||
|
{ signingOrder: 1 },
|
||||||
|
{
|
||||||
|
// Legacy/inconsistent data: a CC recipient that was never marked as signed.
|
||||||
|
signingOrder: 2,
|
||||||
|
role: RecipientRole.CC,
|
||||||
|
signingStatus: SigningStatus.NOT_SIGNED,
|
||||||
|
},
|
||||||
|
{ signingOrder: 3 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.documentMeta.update({
|
||||||
|
where: {
|
||||||
|
id: document.documentMetaId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstSigner = recipients.find((r) => r.email === 'signer1@example.com');
|
||||||
|
const ccRecipient = recipients.find((r) => r.email === 'cc@example.com');
|
||||||
|
const lastSigner = recipients.find((r) => r.email === 'signer2@example.com');
|
||||||
|
|
||||||
|
// CC recipients cannot have fields.
|
||||||
|
await prisma.field.deleteMany({
|
||||||
|
where: {
|
||||||
|
recipientId: ccRecipient?.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sign as the first signer.
|
||||||
|
await page.goto(`/sign/${firstSigner?.token}`);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||||
|
await signSignaturePad(page);
|
||||||
|
|
||||||
|
const firstSignerField = await prisma.field.findFirstOrThrow({
|
||||||
|
where: { recipientId: firstSigner?.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.locator(`#field-${firstSignerField.id}`).getByRole('button').click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Sign' }).click();
|
||||||
|
await page.waitForURL(`/sign/${firstSigner?.token}/complete`);
|
||||||
|
|
||||||
|
// The unsigned CC recipient at order 2 must not block the last signer at order 3.
|
||||||
|
await page.goto(`/sign/${lastSigner?.token}`);
|
||||||
|
await expect(page).not.toHaveURL(`/sign/${lastSigner?.token}/waiting`);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||||
|
|
||||||
|
await signSignaturePad(page);
|
||||||
|
|
||||||
|
const lastSignerField = await prisma.field.findFirstOrThrow({
|
||||||
|
where: { recipientId: lastSigner?.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.locator(`#field-${lastSignerField.id}`).getByRole('button').click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Sign' }).click();
|
||||||
|
await page.waitForURL(`/sign/${lastSigner?.token}/complete`);
|
||||||
|
|
||||||
|
// The document completes without any action from the CC recipient.
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const finalDocument = await prisma.envelope.findFirstOrThrow({
|
||||||
|
where: { id: document.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return finalDocument.status;
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(DocumentStatus.COMPLETED);
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,7 +10,14 @@ import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
|
|||||||
import { seedTeam, seedTeamEmail, seedTeamMember } from '@documenso/prisma/seed/teams';
|
import { seedTeam, seedTeamEmail, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import { DocumentStatus, DocumentVisibility, OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
|
import {
|
||||||
|
DocumentStatus,
|
||||||
|
DocumentVisibility,
|
||||||
|
OrganisationMemberRole,
|
||||||
|
RecipientRole,
|
||||||
|
SigningStatus,
|
||||||
|
TeamMemberRole,
|
||||||
|
} from '@prisma/client';
|
||||||
|
|
||||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||||
import { checkDocumentTabCount } from '../fixtures/documents';
|
import { checkDocumentTabCount } from '../fixtures/documents';
|
||||||
@@ -1165,3 +1172,132 @@ test.describe('Find Documents UI - Sender Filter', () => {
|
|||||||
await expect(page.getByRole('link', { name: 'Member1 Sent Doc' })).toBeVisible();
|
await expect(page.getByRole('link', { name: 'Member1 Sent Doc' })).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('Find Documents UI - Rejected and Expired Tabs', () => {
|
||||||
|
const PAST = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
test('rejected tab lists rejected documents and counts them independently', async ({ page }) => {
|
||||||
|
const { user: owner, team } = await seedUser();
|
||||||
|
const { user: recipient } = await seedUser();
|
||||||
|
|
||||||
|
// A rejected document: envelope status REJECTED + a recipient who rejected.
|
||||||
|
const rejectedDoc = await seedPendingDocument(owner, team.id, [recipient], {
|
||||||
|
createDocumentOptions: { title: 'Rejected Doc' },
|
||||||
|
});
|
||||||
|
await prisma.envelope.update({
|
||||||
|
where: { id: rejectedDoc.id },
|
||||||
|
data: { status: DocumentStatus.REJECTED },
|
||||||
|
});
|
||||||
|
await prisma.recipient.updateMany({
|
||||||
|
where: { envelopeId: rejectedDoc.id },
|
||||||
|
data: { signingStatus: SigningStatus.REJECTED },
|
||||||
|
});
|
||||||
|
|
||||||
|
// A plain pending document (noise — must not appear under Rejected).
|
||||||
|
await seedPendingDocument(owner, team.id, [recipient], {
|
||||||
|
createDocumentOptions: { title: 'Plain Pending Doc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: owner.email,
|
||||||
|
redirectPath: `/t/${team.url}/documents`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await checkDocumentTabCount(page, 'Rejected', 1);
|
||||||
|
await expect(page.getByRole('link', { name: 'Rejected Doc' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'Plain Pending Doc' })).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('expired tab lists documents with an expired recipient and shows empty state otherwise', async ({ page }) => {
|
||||||
|
const { user: owner, team } = await seedUser();
|
||||||
|
const { user: recipient } = await seedUser();
|
||||||
|
|
||||||
|
const expiredDoc = await seedPendingDocument(owner, team.id, [recipient], {
|
||||||
|
createDocumentOptions: { title: 'Expired Doc' },
|
||||||
|
});
|
||||||
|
await prisma.recipient.updateMany({
|
||||||
|
where: { envelopeId: expiredDoc.id },
|
||||||
|
data: { expiresAt: PAST },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Active pending doc — recipient link not expired.
|
||||||
|
await seedPendingDocument(owner, team.id, [recipient], {
|
||||||
|
createDocumentOptions: { title: 'Active Doc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: owner.email,
|
||||||
|
redirectPath: `/t/${team.url}/documents`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expired doc is still PENDING, so it appears under both Pending and Expired.
|
||||||
|
await checkDocumentTabCount(page, 'Pending', 2);
|
||||||
|
await checkDocumentTabCount(page, 'Expired', 1);
|
||||||
|
await expect(page.getByRole('link', { name: 'Expired Doc' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'Active Doc' })).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('expired tab excludes signed and CC recipients', async ({ page }) => {
|
||||||
|
const { user: owner, team } = await seedUser();
|
||||||
|
const { user: recipient } = await seedUser();
|
||||||
|
|
||||||
|
// Expired but already signed — must NOT count as expired.
|
||||||
|
const signedDoc = await seedPendingDocument(owner, team.id, [recipient], {
|
||||||
|
createDocumentOptions: { title: 'Expired Signed Doc' },
|
||||||
|
});
|
||||||
|
await prisma.recipient.updateMany({
|
||||||
|
where: { envelopeId: signedDoc.id },
|
||||||
|
data: { expiresAt: PAST, signingStatus: SigningStatus.SIGNED },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expired but CC — must NOT count as expired.
|
||||||
|
const ccDoc = await seedPendingDocument(owner, team.id, [recipient], {
|
||||||
|
createDocumentOptions: { title: 'Expired CC Doc' },
|
||||||
|
});
|
||||||
|
await prisma.recipient.updateMany({
|
||||||
|
where: { envelopeId: ccDoc.id },
|
||||||
|
data: { expiresAt: PAST, role: RecipientRole.CC },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expired, unsigned, non-CC — the only one that should appear.
|
||||||
|
const validDoc = await seedPendingDocument(owner, team.id, [recipient], {
|
||||||
|
createDocumentOptions: { title: 'Expired Valid Doc' },
|
||||||
|
});
|
||||||
|
await prisma.recipient.updateMany({
|
||||||
|
where: { envelopeId: validDoc.id },
|
||||||
|
data: { expiresAt: PAST },
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: owner.email,
|
||||||
|
redirectPath: `/t/${team.url}/documents`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await checkDocumentTabCount(page, 'Expired', 1);
|
||||||
|
await expect(page.getByRole('link', { name: 'Expired Valid Doc' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'Expired Signed Doc' })).not.toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'Expired CC Doc' })).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejected and expired tabs show tailored empty states when nothing matches', async ({ page }) => {
|
||||||
|
const { user: owner, team } = await seedUser();
|
||||||
|
const { user: recipient } = await seedUser();
|
||||||
|
|
||||||
|
await seedPendingDocument(owner, team.id, [recipient], {
|
||||||
|
createDocumentOptions: { title: 'Just Pending' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: owner.email,
|
||||||
|
redirectPath: `/t/${team.url}/documents`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// count === 0 asserts the empty-document-state is visible.
|
||||||
|
await checkDocumentTabCount(page, 'Rejected', 0);
|
||||||
|
await checkDocumentTabCount(page, 'Expired', 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { seedDirectTemplate } from '@documenso/prisma/seed/templates';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
import { expect, type Page, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
import { clickEnvelopeEditorStep } from '../fixtures/envelope-editor';
|
||||||
|
|
||||||
|
const INVALID_DIRECT_TEMPLATE_ALERT_TITLE = 'Invalid direct link template';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place a field on the PDF canvas in the envelope editor.
|
||||||
|
*/
|
||||||
|
const placeFieldOnPdf = async (root: Page, fieldName: 'Signature' | 'Text', position: { x: number; y: number }) => {
|
||||||
|
await root.getByRole('button', { name: fieldName, exact: true }).click();
|
||||||
|
|
||||||
|
const canvas = root.locator('.konva-container canvas').first();
|
||||||
|
await expect(canvas).toBeVisible();
|
||||||
|
await canvas.click({ position });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed a V2 direct template and open it in the native template editor.
|
||||||
|
*
|
||||||
|
* Only the native template editor is covered here: direct links only exist
|
||||||
|
* for templates and are not part of the embedded editor surfaces.
|
||||||
|
*/
|
||||||
|
const openDirectTemplateEditor = async (page: Page, options: { createDirectRecipientSignatureField: boolean }) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
const template = await seedDirectTemplate({
|
||||||
|
title: `E2E Direct Template Validation ${Date.now()}`,
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
internalVersion: 2,
|
||||||
|
createDirectRecipientSignatureField: options.createDirectRecipientSignatureField,
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { user, team, template };
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('template editor', () => {
|
||||||
|
test('shows invalid direct template warning when a signer has no signature field', async ({ page }) => {
|
||||||
|
await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: false });
|
||||||
|
|
||||||
|
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).toBeVisible();
|
||||||
|
await expect(page.getByText('are missing a signature field')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not show the warning when all signers have signature fields', async ({ page }) => {
|
||||||
|
await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: true });
|
||||||
|
|
||||||
|
// Wait for the editor to render before asserting the banner is absent.
|
||||||
|
await expect(page.getByTestId('envelope-editor-step-upload')).toBeVisible();
|
||||||
|
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('warning disappears after placing a signature field', async ({ page }) => {
|
||||||
|
await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: false });
|
||||||
|
|
||||||
|
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).toBeVisible();
|
||||||
|
|
||||||
|
// Place a signature field for the direct recipient (auto-selected single recipient).
|
||||||
|
await clickEnvelopeEditorStep(page, 'addFields');
|
||||||
|
await expect(page.locator('.konva-container canvas').first()).toBeVisible();
|
||||||
|
await placeFieldOnPdf(page, 'Signature', { x: 120, y: 140 });
|
||||||
|
|
||||||
|
// The banner clears once the field is autosaved and the envelope state updates.
|
||||||
|
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).not.toBeVisible({ timeout: 15_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
|
||||||
|
|
||||||
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
import {
|
||||||
|
assertRecipientRole,
|
||||||
|
getRecipientEmailInputs,
|
||||||
|
getRecipientRows,
|
||||||
|
getSigningOrderInputs,
|
||||||
|
openDocumentEnvelopeEditor,
|
||||||
|
setRecipientEmail,
|
||||||
|
setRecipientName,
|
||||||
|
setRecipientRole,
|
||||||
|
toggleSigningOrder,
|
||||||
|
} from '../fixtures/envelope-editor';
|
||||||
|
|
||||||
|
const SIGNER_A = { email: 'cc-order-signer-a@example.com', name: 'Signer A' };
|
||||||
|
const SIGNER_B = { email: 'cc-order-signer-b@example.com', name: 'Signer B' };
|
||||||
|
const CC_RECIPIENT = { email: 'cc-order-cc@example.com', name: 'CC Recipient' };
|
||||||
|
|
||||||
|
const assertCcDisplayedLastWithNoOrderInput = async (root: Page) => {
|
||||||
|
// CC recipient is displayed last despite being added/stored mid-list.
|
||||||
|
await expect(getRecipientEmailInputs(root)).toHaveCount(3);
|
||||||
|
await expect(getRecipientEmailInputs(root).nth(0)).toHaveValue(SIGNER_A.email);
|
||||||
|
await expect(getRecipientEmailInputs(root).nth(1)).toHaveValue(SIGNER_B.email);
|
||||||
|
await expect(getRecipientEmailInputs(root).nth(2)).toHaveValue(CC_RECIPIENT.email);
|
||||||
|
|
||||||
|
await assertRecipientRole(root, 0, 'Needs to sign');
|
||||||
|
await assertRecipientRole(root, 1, 'Needs to sign');
|
||||||
|
await assertRecipientRole(root, 2, 'Receives copy');
|
||||||
|
|
||||||
|
// Only the two signers have signing order inputs, showing 1 and 2.
|
||||||
|
await expect(getSigningOrderInputs(root)).toHaveCount(2);
|
||||||
|
await expect(getSigningOrderInputs(root).nth(0)).toHaveValue('1');
|
||||||
|
await expect(getSigningOrderInputs(root).nth(1)).toHaveValue('2');
|
||||||
|
|
||||||
|
// The CC row itself renders no signing order input (placeholder div instead).
|
||||||
|
const ccRow = getRecipientRows(root).nth(2);
|
||||||
|
await expect(ccRow.locator('[data-testid="signing-order-input"]')).toHaveCount(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('document editor', () => {
|
||||||
|
test('CC recipient added mid-list is displayed last with no signing order input', async ({ page }) => {
|
||||||
|
const surface = await openDocumentEnvelopeEditor(page);
|
||||||
|
const { root } = surface;
|
||||||
|
|
||||||
|
await toggleSigningOrder(root, true);
|
||||||
|
|
||||||
|
// Add signer A into the initial empty row.
|
||||||
|
await setRecipientEmail(root, 0, SIGNER_A.email);
|
||||||
|
await setRecipientName(root, 0, SIGNER_A.name);
|
||||||
|
|
||||||
|
// Add the CC recipient second.
|
||||||
|
await root.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
|
await setRecipientEmail(root, 1, CC_RECIPIENT.email);
|
||||||
|
await setRecipientName(root, 1, CC_RECIPIENT.name);
|
||||||
|
await setRecipientRole(root, 1, 'Receives copy');
|
||||||
|
|
||||||
|
// Once the row becomes CC, its signing order input disappears.
|
||||||
|
await expect(getSigningOrderInputs(root)).toHaveCount(1);
|
||||||
|
|
||||||
|
// Add signer B third. The new row is inserted before the CC recipient,
|
||||||
|
// which is kept last by the client-side sorting.
|
||||||
|
await root.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
|
await expect(getRecipientEmailInputs(root).nth(2)).toHaveValue(CC_RECIPIENT.email);
|
||||||
|
|
||||||
|
await setRecipientEmail(root, 1, SIGNER_B.email);
|
||||||
|
await setRecipientName(root, 1, SIGNER_B.name);
|
||||||
|
|
||||||
|
await assertCcDisplayedLastWithNoOrderInput(root);
|
||||||
|
|
||||||
|
// The editor autosaves with a debounce, poll the DB until all three
|
||||||
|
// recipients have been persisted before reloading the page.
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const recipients = await prisma.recipient.findMany({
|
||||||
|
where: { envelopeId: surface.envelopeId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return recipients.length;
|
||||||
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.toBe(3);
|
||||||
|
|
||||||
|
// Reload the editor and assert the CC recipient is still displayed last.
|
||||||
|
await root.reload();
|
||||||
|
await expect(root.getByRole('heading', { name: 'Recipients' })).toBeVisible();
|
||||||
|
|
||||||
|
await assertCcDisplayedLastWithNoOrderInput(root);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CC recipient seeded with mid-list signing order is displayed last', async ({ page }) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
const document = await seedBlankDocument(user, team.id, {
|
||||||
|
internalVersion: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seed a CC recipient directly in the DB with a mid-list signing order
|
||||||
|
// (2 of 3) BEFORE opening the editor, so the editor's autosave cannot
|
||||||
|
// race with the seeded recipients, and assert the editor renders it last.
|
||||||
|
await prisma.envelope.update({
|
||||||
|
where: { id: document.id },
|
||||||
|
data: {
|
||||||
|
documentMeta: {
|
||||||
|
update: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||||
|
},
|
||||||
|
recipients: {
|
||||||
|
createMany: {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
email: SIGNER_A.email,
|
||||||
|
name: SIGNER_A.name,
|
||||||
|
token: nanoid(),
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
signingOrder: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: CC_RECIPIENT.email,
|
||||||
|
name: CC_RECIPIENT.name,
|
||||||
|
token: nanoid(),
|
||||||
|
role: RecipientRole.CC,
|
||||||
|
signingOrder: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: SIGNER_B.email,
|
||||||
|
name: SIGNER_B.name,
|
||||||
|
token: nanoid(),
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
signingOrder: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/t/${team.url}/documents/${document.id}/edit?step=uploadAndRecipients`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Recipients' })).toBeVisible();
|
||||||
|
|
||||||
|
await assertCcDisplayedLastWithNoOrderInput(page);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { expect } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the app command menu via the keyboard shortcut.
|
||||||
|
*
|
||||||
|
* Retries the shortcut until the menu appears since the keypress is a no-op
|
||||||
|
* when it happens before the page has hydrated.
|
||||||
|
*
|
||||||
|
* @param placeholder The search input placeholder to wait for, which differs
|
||||||
|
* between admin and non-admin users.
|
||||||
|
*/
|
||||||
|
export const openCommandMenu = async (page: Page, placeholder: string) => {
|
||||||
|
await expect(async () => {
|
||||||
|
await page.keyboard.press('Meta+K');
|
||||||
|
await expect(page.getByPlaceholder(placeholder).first()).toBeVisible({ timeout: 1_000 });
|
||||||
|
}).toPass({ timeout: 15_000 });
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ import { expect, test } from '@playwright/test';
|
|||||||
|
|
||||||
import { apiSignin } from '../fixtures/authentication';
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||||
|
import { signSignaturePad } from '../fixtures/signature';
|
||||||
|
|
||||||
test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
|
test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
|
||||||
const { user, team } = await seedUser();
|
const { user, team } = await seedUser();
|
||||||
@@ -73,8 +74,19 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
|
|||||||
await expect(page.locator('body')).toContainText('public-direct-template-title');
|
await expect(page.locator('body')).toContainText('public-direct-template-title');
|
||||||
await expect(page.locator('body')).toContainText('public-direct-template-description');
|
await expect(page.locator('body')).toContainText('public-direct-template-description');
|
||||||
|
|
||||||
|
const directSignatureField = directTemplate.fields[0];
|
||||||
|
|
||||||
|
if (!directSignatureField) {
|
||||||
|
throw new Error('Expected seeded direct template signature field to exist');
|
||||||
|
}
|
||||||
|
|
||||||
await page.getByRole('link', { name: 'Sign' }).click();
|
await page.getByRole('link', { name: 'Sign' }).click();
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
await signSignaturePad(page);
|
||||||
|
await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click();
|
||||||
|
await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Complete' }).click();
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
await page.getByRole('button', { name: 'Sign' }).click();
|
await page.getByRole('button', { name: 'Sign' }).click();
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ test.describe('Default Recipients', () => {
|
|||||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
|
|
||||||
// Add a regular signer using the v2 editor
|
// Add a regular signer using the v2 editor
|
||||||
await page.getByTestId('signer-email-input').last().fill('regular-signer@documenso.com');
|
await page.getByTestId('signer-email-input').first().fill('regular-signer@documenso.com');
|
||||||
await page
|
await page
|
||||||
.getByPlaceholder(/Recipient/)
|
.getByPlaceholder(/Recipient/)
|
||||||
.first()
|
.first()
|
||||||
|
|||||||
@@ -197,7 +197,18 @@ test('[DIRECT_TEMPLATES]: V1 direct template link auth access', async ({ page })
|
|||||||
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||||
await expect(page.getByLabel('Email')).toBeDisabled();
|
await expect(page.getByLabel('Email')).toBeDisabled();
|
||||||
|
|
||||||
|
const directSignatureField = directTemplateWithAuth.fields[0];
|
||||||
|
|
||||||
|
if (!directSignatureField) {
|
||||||
|
throw new Error('Expected seeded direct template signature field to exist');
|
||||||
|
}
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
await signSignaturePad(page);
|
||||||
|
await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click();
|
||||||
|
await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Complete' }).click();
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Sign' }).click();
|
await page.getByRole('button', { name: 'Sign' }).click();
|
||||||
@@ -235,6 +246,37 @@ test('[DIRECT_TEMPLATES]: V2 direct template link auth access', async ({ page })
|
|||||||
await page.goto(directTemplatePath);
|
await page.goto(directTemplatePath);
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Personal direct template link' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Personal direct template link' })).toBeVisible();
|
||||||
|
|
||||||
|
const directSignatureField = directTemplateWithAuth.fields[0];
|
||||||
|
|
||||||
|
if (!directSignatureField) {
|
||||||
|
throw new Error('Expected seeded direct template signature field to exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the PDF and the Konva canvas overlay to be ready.
|
||||||
|
await expect(page.locator('img[data-page-number]').first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
const canvas = page.locator('.konva-container canvas').first();
|
||||||
|
await expect(canvas).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
// Sign the direct template recipient's signature field via the canvas-based V2 UI.
|
||||||
|
await signSignaturePad(page);
|
||||||
|
|
||||||
|
const canvasBox = await canvas.boundingBox();
|
||||||
|
|
||||||
|
if (!canvasBox) {
|
||||||
|
throw new Error('Canvas bounding box not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const x =
|
||||||
|
(Number(directSignatureField.positionX) / 100) * canvasBox.width +
|
||||||
|
((Number(directSignatureField.width) / 100) * canvasBox.width) / 2;
|
||||||
|
const y =
|
||||||
|
(Number(directSignatureField.positionY) / 100) * canvasBox.height +
|
||||||
|
((Number(directSignatureField.height) / 100) * canvasBox.height) / 2;
|
||||||
|
|
||||||
|
await canvas.click({ position: { x, y } });
|
||||||
|
await expect(page.getByText('0 Fields Remaining').first()).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Complete' }).click();
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
await expect(page.getByLabel('Your Email')).not.toBeVisible();
|
await expect(page.getByLabel('Your Email')).not.toBeVisible();
|
||||||
|
|
||||||
@@ -266,6 +308,16 @@ test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ p
|
|||||||
|
|
||||||
await expect(page.getByText('Next Recipient Name')).not.toBeVisible();
|
await expect(page.getByText('Next Recipient Name')).not.toBeVisible();
|
||||||
|
|
||||||
|
const directSignatureField = template.fields[0];
|
||||||
|
|
||||||
|
if (!directSignatureField) {
|
||||||
|
throw new Error('Expected seeded direct template signature field to exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
await signSignaturePad(page);
|
||||||
|
await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click();
|
||||||
|
await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Complete' }).click();
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
await page.getByRole('button', { name: 'Sign' }).click();
|
await page.getByRole('button', { name: 'Sign' }).click();
|
||||||
await page.waitForURL(/\/sign/);
|
await page.waitForURL(/\/sign/);
|
||||||
@@ -299,19 +351,13 @@ test('[DIRECT_TEMPLATES]: V1 use direct template link with 2 recipients with nex
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const directTemplateRecipient = template.recipients[0];
|
// The seeded direct template already includes a signature field for the direct recipient.
|
||||||
|
const directSignatureField = template.fields[0];
|
||||||
|
|
||||||
if (!directTemplateRecipient) {
|
if (!directSignatureField) {
|
||||||
throw new Error('Expected direct template recipient to exist');
|
throw new Error('Expected seeded direct template signature field to exist');
|
||||||
}
|
}
|
||||||
|
|
||||||
// All SIGNER recipients need a signature field for sendDocument to dispatch emails.
|
|
||||||
const directSignatureField = await seedSignatureFieldForRecipient({
|
|
||||||
envelopeId: template.id,
|
|
||||||
recipientId: directTemplateRecipient.id,
|
|
||||||
positionY: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
const originalName = 'Signer 2';
|
const originalName = 'Signer 2';
|
||||||
const originalSecondSignerEmail = seedTestEmail();
|
const originalSecondSignerEmail = seedTestEmail();
|
||||||
|
|
||||||
@@ -413,19 +459,13 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const directTemplateRecipient = template.recipients[0];
|
// The seeded direct template already includes a signature field for the direct recipient.
|
||||||
|
const directSignatureField = template.fields[0];
|
||||||
|
|
||||||
if (!directTemplateRecipient) {
|
if (!directSignatureField) {
|
||||||
throw new Error('Expected direct template recipient to exist');
|
throw new Error('Expected seeded direct template signature field to exist');
|
||||||
}
|
}
|
||||||
|
|
||||||
// All SIGNER recipients need a signature field for sendDocument to dispatch emails.
|
|
||||||
const directSignatureField = await seedSignatureFieldForRecipient({
|
|
||||||
envelopeId: template.id,
|
|
||||||
recipientId: directTemplateRecipient.id,
|
|
||||||
positionY: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
const originalName = 'Signer 2';
|
const originalName = 'Signer 2';
|
||||||
const originalSecondSignerEmail = seedTestEmail();
|
const originalSecondSignerEmail = seedTestEmail();
|
||||||
|
|
||||||
@@ -521,3 +561,48 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex
|
|||||||
expect(updatedSecondRecipient.email).toBe(newSecondSignerEmail);
|
expect(updatedSecondRecipient.email).toBe(newSecondSignerEmail);
|
||||||
await expectSigningRequestJobForRecipient(updatedSecondRecipient.id);
|
await expectSigningRequestJobForRecipient(updatedSecondRecipient.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('[DIRECT_TEMPLATES]: V1 direct template without signature fields shows invalid template page', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
const template = await seedDirectTemplate({
|
||||||
|
title: 'V1 invalid direct template',
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
createDirectRecipientSignatureField: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Invalid direct link template' })).toBeVisible();
|
||||||
|
await expect(page.getByText('This direct link template cannot be used because one or more signers')).toBeVisible();
|
||||||
|
|
||||||
|
// The signing flow must not render.
|
||||||
|
await expect(page.getByRole('heading', { name: 'General' })).not.toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Continue' })).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[DIRECT_TEMPLATES]: V2 direct template without signature fields shows invalid template page', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
const template = await seedDirectTemplate({
|
||||||
|
title: 'V2 invalid direct template',
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
internalVersion: 2,
|
||||||
|
createDirectRecipientSignatureField: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Invalid direct link template' })).toBeVisible();
|
||||||
|
await expect(page.getByText('This direct link template cannot be used because one or more signers')).toBeVisible();
|
||||||
|
|
||||||
|
// The signing flow (PDF canvas) must not render.
|
||||||
|
await expect(page.locator('.konva-container canvas')).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('button', { name: 'Complete' })).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { FIELD_SIGNATURE_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { seedTemplate } from '@documenso/prisma/seed/templates';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { DocumentStatus, FieldType } from '@prisma/client';
|
||||||
|
|
||||||
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||||
|
|
||||||
|
const seedSignatureFieldForRecipient = async (options: { envelopeId: string; recipientId: number }) => {
|
||||||
|
const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({
|
||||||
|
where: { envelopeId: options.envelopeId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return await prisma.field.create({
|
||||||
|
data: {
|
||||||
|
envelopeId: options.envelopeId,
|
||||||
|
envelopeItemId: envelopeItem.id,
|
||||||
|
recipientId: options.recipientId,
|
||||||
|
type: FieldType.SIGNATURE,
|
||||||
|
page: 1,
|
||||||
|
positionX: 5,
|
||||||
|
positionY: 10,
|
||||||
|
width: 20,
|
||||||
|
height: 5,
|
||||||
|
customText: '',
|
||||||
|
inserted: false,
|
||||||
|
fieldMeta: FIELD_SIGNATURE_META_DEFAULT_VALUES,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
test('[TEMPLATE_USE]: shows missing signature fields error when sending a template without signature fields', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
// seedTemplate creates one SIGNER recipient and no fields.
|
||||||
|
await seedTemplate({
|
||||||
|
title: 'Template missing signature fields',
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/t/${team.url}/templates`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Create document from template' })).toBeVisible();
|
||||||
|
|
||||||
|
// Enable distribution so the document is sent on creation.
|
||||||
|
await page.locator('#distributeDocument').click();
|
||||||
|
await page.getByRole('button', { name: 'Create and send' }).click();
|
||||||
|
|
||||||
|
await expectToastTextToBeVisible(page, 'Missing signature fields');
|
||||||
|
await expectToastTextToBeVisible(
|
||||||
|
page,
|
||||||
|
'The document could not be sent because some signers do not have a signature field',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEMPLATE_USE]: creates and sends a document when signers have signature fields', async ({ page }) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
const template = await seedTemplate({
|
||||||
|
title: 'Template with signature fields',
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await seedSignatureFieldForRecipient({
|
||||||
|
envelopeId: template.id,
|
||||||
|
recipientId: template.recipients[0].id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/t/${team.url}/templates`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Create document from template' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator('#distributeDocument').click();
|
||||||
|
await page.getByRole('button', { name: 'Create and send' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
|
||||||
|
|
||||||
|
const envelopeId = page.url().split('/').pop()?.split('?')[0];
|
||||||
|
|
||||||
|
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||||
|
where: { id: envelopeId },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(envelope.status).toBe(DocumentStatus.PENDING);
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { OrganisationType, type Prisma, SubscriptionStatus } from '@prisma/client';
|
import { OrganisationType, type Prisma, SubscriptionStatus } from '@prisma/client';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
import { reconcileSeatBasedPlans } from './update-subscription-item-quantity';
|
||||||
|
|
||||||
const LIVE_SUBSCRIPTION_STATUSES: Stripe.Subscription.Status[] = ['active', 'trialing', 'past_due'];
|
const LIVE_SUBSCRIPTION_STATUSES: Stripe.Subscription.Status[] = ['active', 'trialing', 'past_due'];
|
||||||
|
|
||||||
@@ -229,6 +230,19 @@ const handleLiveSubscription = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Detect a billing-period roll by comparing the persisted period end with
|
||||||
|
// the freshly-fetched one — the convergent equivalent of the old
|
||||||
|
// `previous_attributes.current_period_start` signal. On renewal, reconcile
|
||||||
|
// the seat quantity and claim down to the actual member count. The reconcile
|
||||||
|
// itself no-ops for non-seat/unlimited plans and inactive subscriptions.
|
||||||
|
const previousPeriodEnd = organisation.subscription?.periodEnd ?? null;
|
||||||
|
|
||||||
|
const hasPeriodAdvanced = previousPeriodEnd !== null && periodEnd.getTime() > previousPeriodEnd.getTime();
|
||||||
|
|
||||||
|
if (hasPeriodAdvanced && !bypassClaimUpdate) {
|
||||||
|
await reconcileSeatBasedPlans(organisation.id);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { stripe } from '@documenso/lib/server-only/stripe';
|
|||||||
import { appLog } from '@documenso/lib/utils/debugger';
|
import { appLog } from '@documenso/lib/utils/debugger';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import type { OrganisationClaim, Subscription } from '@prisma/client';
|
import type { OrganisationClaim, Subscription } from '@prisma/client';
|
||||||
|
import { SubscriptionStatus } from '@prisma/client';
|
||||||
import type Stripe from 'stripe';
|
import type Stripe from 'stripe';
|
||||||
|
|
||||||
import { isPriceSeatsBased } from './is-price-seats-based';
|
import { isPriceSeatsBased } from './is-price-seats-based';
|
||||||
@@ -11,12 +12,14 @@ export type UpdateSubscriptionItemQuantityOptions = {
|
|||||||
subscriptionId: string;
|
subscriptionId: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
priceId: string;
|
priceId: string;
|
||||||
|
prorationBehaviour: 'always_invoice' | 'none';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateSubscriptionItemQuantity = async ({
|
export const updateSubscriptionItemQuantity = async ({
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
quantity,
|
quantity,
|
||||||
priceId,
|
priceId,
|
||||||
|
prorationBehaviour,
|
||||||
}: UpdateSubscriptionItemQuantityOptions) => {
|
}: UpdateSubscriptionItemQuantityOptions) => {
|
||||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||||
|
|
||||||
@@ -26,7 +29,6 @@ export const updateSubscriptionItemQuantity = async ({
|
|||||||
throw new Error('Subscription does not contain required item');
|
throw new Error('Subscription does not contain required item');
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasYearlyItem = items.find((item) => item.price.recurring?.interval === 'year');
|
|
||||||
const oldQuantity = items[0].quantity;
|
const oldQuantity = items[0].quantity;
|
||||||
|
|
||||||
if (oldQuantity === quantity) {
|
if (oldQuantity === quantity) {
|
||||||
@@ -38,13 +40,12 @@ export const updateSubscriptionItemQuantity = async ({
|
|||||||
id: item.id,
|
id: item.id,
|
||||||
quantity,
|
quantity,
|
||||||
})),
|
})),
|
||||||
|
proration_behavior: prorationBehaviour,
|
||||||
|
// Need to "off_session" updates since adding 3DS will have payments
|
||||||
|
// not pass through for these immediate invoices.
|
||||||
|
off_session: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only invoice immediately when changing the quantity of yearly item.
|
|
||||||
if (hasYearlyItem) {
|
|
||||||
subscriptionUpdatePayload.proration_behavior = 'always_invoice';
|
|
||||||
}
|
|
||||||
|
|
||||||
await stripe.subscriptions.update(subscriptionId, subscriptionUpdatePayload);
|
await stripe.subscriptions.update(subscriptionId, subscriptionUpdatePayload);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,15 +56,19 @@ export const updateSubscriptionItemQuantity = async ({
|
|||||||
* via Stripe rather than enforcing a hard cap. A `memberCount` of `0` on the
|
* via Stripe rather than enforcing a hard cap. A `memberCount` of `0` on the
|
||||||
* organisation claim represents unlimited seats.
|
* organisation claim represents unlimited seats.
|
||||||
*
|
*
|
||||||
|
* Organisations without a subscription (e.g. after being downgraded to the
|
||||||
|
* free plan) can pass `null`, in which case the claim cap is enforced
|
||||||
|
* directly without the seats-based exemption.
|
||||||
|
*
|
||||||
* Should only be called from grow paths (invite/add). Reducing operations
|
* Should only be called from grow paths (invite/add). Reducing operations
|
||||||
* must never be gated by this check.
|
* must never be gated by this check.
|
||||||
*
|
*
|
||||||
* @param subscription - The organisation's Stripe subscription.
|
* @param subscription - The organisation's Stripe subscription, if any.
|
||||||
* @param organisationClaim - The organisation claim.
|
* @param organisationClaim - The organisation claim.
|
||||||
* @param quantity - The proposed total member + pending invite count.
|
* @param quantity - The proposed total member count.
|
||||||
*/
|
*/
|
||||||
export const assertMemberCountWithinCap = async (
|
export const assertMemberCountWithinCap = async (
|
||||||
subscription: Subscription,
|
subscription: Subscription | null,
|
||||||
organisationClaim: OrganisationClaim,
|
organisationClaim: OrganisationClaim,
|
||||||
quantity: number,
|
quantity: number,
|
||||||
) => {
|
) => {
|
||||||
@@ -75,10 +80,12 @@ export const assertMemberCountWithinCap = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Seats-based plans don't have a hard cap; Stripe meters the usage.
|
// Seats-based plans don't have a hard cap; Stripe meters the usage.
|
||||||
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
|
if (subscription) {
|
||||||
|
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
|
||||||
|
|
||||||
if (isSeatsBased) {
|
if (isSeatsBased) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (quantity > maximumMemberCount) {
|
if (quantity > maximumMemberCount) {
|
||||||
@@ -89,48 +96,134 @@ export const assertMemberCountWithinCap = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Syncs the organisation's member count with the Stripe subscription quantity.
|
* Syncs the Stripe subscription quantity with the organisation's member count.
|
||||||
*
|
*
|
||||||
* No-ops for plans that are not seats-based, and for organisations with
|
* This is a Stripe <-> Database sync operation.
|
||||||
* unlimited seats (`organisationClaim.memberCount === 0`). Safe to call from
|
*
|
||||||
* both grow and shrink paths.
|
* Note: `organisationClaim.memberCount` is the paid seat high-water mark for the
|
||||||
|
* current billing period — the highest count we've already billed for.
|
||||||
*
|
*
|
||||||
* @param subscription - The subscription to sync the member count with.
|
* @param subscription - The subscription to sync the member count with.
|
||||||
* @param organisationClaim - The organisation claim.
|
* @param organisationClaim - The organisation claim.
|
||||||
* @param quantity - The new total member + pending invite count to sync.
|
* @param quantity - The new total member count to sync.
|
||||||
|
* @param mode - The member-count change that triggered the sync.
|
||||||
*/
|
*/
|
||||||
export const syncMemberCountWithStripeSeatPlan = async (
|
export const syncMemberCountWithStripeSeatPlan = async (
|
||||||
subscription: Subscription,
|
subscription: Subscription,
|
||||||
organisationClaim: OrganisationClaim,
|
organisationClaim: OrganisationClaim,
|
||||||
quantity: number,
|
quantity: number,
|
||||||
|
mode: 'grow' | 'shrink',
|
||||||
) => {
|
) => {
|
||||||
// Infinite seats means no sync needed.
|
// Unlimited seats — nothing to meter.
|
||||||
if (organisationClaim.memberCount === 0) {
|
if (organisationClaim.memberCount === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
|
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
|
||||||
|
|
||||||
|
// Only seat-based plans support seat syncing.
|
||||||
if (!isSeatsBased) {
|
if (!isSeatsBased) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
appLog('BILLING', 'Updating seat based plan');
|
// Whether to immediately invoice for new seats if the quantity is greater than
|
||||||
|
// the high-water mark.
|
||||||
|
const billsForNewSeats = mode === 'grow' && quantity > organisationClaim.memberCount;
|
||||||
|
|
||||||
|
appLog('BILLING', `Syncing seat based plan (${mode}, quantity ${quantity})`);
|
||||||
|
|
||||||
await updateSubscriptionItemQuantity({
|
await updateSubscriptionItemQuantity({
|
||||||
priceId: subscription.priceId,
|
priceId: subscription.priceId,
|
||||||
subscriptionId: subscription.planId,
|
subscriptionId: subscription.planId,
|
||||||
quantity,
|
quantity,
|
||||||
|
prorationBehaviour: billsForNewSeats ? 'always_invoice' : 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Advance the high-water mark when billing for new seats; it is reset to the
|
||||||
|
// actual member count when the billing period rolls over. Re-adds and shrinks
|
||||||
|
// deliberately leave it untouched so a seat already paid for this period is
|
||||||
|
// never re-charged.
|
||||||
|
if (billsForNewSeats) {
|
||||||
|
await prisma.organisationClaim.update({
|
||||||
|
where: {
|
||||||
|
id: organisationClaim.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
memberCount: quantity,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconciles the organisation claim seat counter, and the stripe quantity with the
|
||||||
|
* actual member count.
|
||||||
|
*
|
||||||
|
* Uses the member count as the authoritative source of truth. Meaning:
|
||||||
|
* - Update the organisation claim with the member count
|
||||||
|
* - Update the Stripe subscription quantity to the member count
|
||||||
|
*
|
||||||
|
* This should only be called when the billing period rolls over.
|
||||||
|
*/
|
||||||
|
export const reconcileSeatBasedPlans = async (organisationId: string) => {
|
||||||
|
const organisation = await prisma.organisation.findFirst({
|
||||||
|
where: {
|
||||||
|
id: organisationId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
organisationClaim: true,
|
||||||
|
subscription: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!organisation || !organisation.subscription) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { subscription, organisationClaim } = organisation;
|
||||||
|
|
||||||
|
// Stripe rejects quantity updates on canceled subscriptions. PAST_DUE is
|
||||||
|
// still live and a no-proration sync is safe, so it's allowed through.
|
||||||
|
if (subscription.status === SubscriptionStatus.INACTIVE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlimited seats — nothing to meter.
|
||||||
|
if (organisationClaim.memberCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
|
||||||
|
|
||||||
|
// Only seat-based plans support seat syncing.
|
||||||
|
if (!isSeatsBased) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberCount = await prisma.organisationMember.count({
|
||||||
|
where: {
|
||||||
|
organisationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// An organisation always retains its owner; never write the unlimited sentinel.
|
||||||
|
if (memberCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateSubscriptionItemQuantity({
|
||||||
|
priceId: subscription.priceId,
|
||||||
|
subscriptionId: subscription.planId,
|
||||||
|
quantity: memberCount,
|
||||||
|
prorationBehaviour: 'none',
|
||||||
});
|
});
|
||||||
|
|
||||||
// This should be automatically updated after the Stripe webhook is fired
|
|
||||||
// but we just manually adjust it here as well to avoid any race conditions.
|
|
||||||
await prisma.organisationClaim.update({
|
await prisma.organisationClaim.update({
|
||||||
where: {
|
where: {
|
||||||
id: organisationClaim.id,
|
id: organisationClaim.id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
memberCount: quantity,
|
memberCount,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
|||||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||||
import { env } from '@documenso/lib/utils/env';
|
import { env } from '@documenso/lib/utils/env';
|
||||||
|
|
||||||
import { syncStripeCustomerSubscription } from '../sync-stripe-customer-subscription';
|
import { syncStripeCustomerSubscription } from '../sync-stripe-customer-subscription';
|
||||||
|
|
||||||
type StripeWebhookResponse = {
|
type StripeWebhookResponse = {
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
|
|||||||
import { useId } from 'react';
|
import { useId } from 'react';
|
||||||
import type { UseFormReturn } from 'react-hook-form';
|
import type { UseFormReturn } from 'react-hook-form';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { prop, sortBy } from 'remeda';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { isCcRecipient, normalizeRecipientSigningOrders, sortRecipientsForSigningOrder } from '../../utils/recipients';
|
||||||
|
|
||||||
const LocalRecipientSchema = z.object({
|
const LocalRecipientSchema = z.object({
|
||||||
formId: z.string().min(1),
|
formId: z.string().min(1),
|
||||||
id: z.number().optional(),
|
id: z.number().optional(),
|
||||||
@@ -94,13 +95,13 @@ export const useEditorRecipients = ({ envelope }: EditorRecipientsProps): UseEdi
|
|||||||
name: recipient.name,
|
name: recipient.name,
|
||||||
email: recipient.email,
|
email: recipient.email,
|
||||||
role: recipient.role,
|
role: recipient.role,
|
||||||
signingOrder: recipient.signingOrder ?? index + 1,
|
signingOrder: isCcRecipient(recipient) ? undefined : (recipient.signingOrder ?? index + 1),
|
||||||
actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
|
actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const signers: TLocalRecipient[] =
|
const signers: TLocalRecipient[] =
|
||||||
formRecipients.length > 0
|
formRecipients.length > 0
|
||||||
? sortBy(formRecipients, [prop('signingOrder'), 'asc'], [prop('id'), 'asc'])
|
? normalizeRecipientSigningOrders(sortRecipientsForSigningOrder(formRecipients))
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
formId: initialId,
|
formId: initialId,
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ export enum AppErrorCode {
|
|||||||
*/
|
*/
|
||||||
ENVELOPE_TSP_LOCKED = 'ENVELOPE_TSP_LOCKED',
|
ENVELOPE_TSP_LOCKED = 'ENVELOPE_TSP_LOCKED',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A signer recipient does not have a signature field assigned. Thrown when
|
||||||
|
* distributing an envelope or using a direct template where at least one
|
||||||
|
* signer has no signature field.
|
||||||
|
*/
|
||||||
|
MISSING_SIGNATURE_FIELD = 'MISSING_SIGNATURE_FIELD',
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSC (Cloud Signature Consortium) error codes. See the CSC QES V1 spec
|
* CSC (Cloud Signature Consortium) error codes. See the CSC QES V1 spec
|
||||||
* for the recovery taxonomy.
|
* for the recovery taxonomy.
|
||||||
@@ -84,6 +91,7 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
|
|||||||
[AppErrorCode.ENVELOPE_CANCELLED]: { code: 'BAD_REQUEST', status: 400 },
|
[AppErrorCode.ENVELOPE_CANCELLED]: { code: 'BAD_REQUEST', status: 400 },
|
||||||
[AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 },
|
[AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 },
|
||||||
[AppErrorCode.ENVELOPE_TSP_LOCKED]: { code: 'BAD_REQUEST', status: 400 },
|
[AppErrorCode.ENVELOPE_TSP_LOCKED]: { code: 'BAD_REQUEST', status: 400 },
|
||||||
|
[AppErrorCode.MISSING_SIGNATURE_FIELD]: { code: 'BAD_REQUEST', status: 400 },
|
||||||
[AppErrorCode.CSC_INSTANCE_MODE_MISMATCH]: { code: 'BAD_REQUEST', status: 400 },
|
[AppErrorCode.CSC_INSTANCE_MODE_MISMATCH]: { code: 'BAD_REQUEST', status: 400 },
|
||||||
[AppErrorCode.CSC_UNLICENSED]: { code: 'FORBIDDEN', status: 403 },
|
[AppErrorCode.CSC_UNLICENSED]: { code: 'FORBIDDEN', status: 403 },
|
||||||
[AppErrorCode.CSC_PROVIDER_INFO_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
[AppErrorCode.CSC_PROVIDER_INFO_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
||||||
@@ -291,6 +299,7 @@ export class AppError extends Error {
|
|||||||
AppErrorCode.ENVELOPE_CANCELLED,
|
AppErrorCode.ENVELOPE_CANCELLED,
|
||||||
AppErrorCode.ENVELOPE_LEGACY,
|
AppErrorCode.ENVELOPE_LEGACY,
|
||||||
AppErrorCode.ENVELOPE_TSP_LOCKED,
|
AppErrorCode.ENVELOPE_TSP_LOCKED,
|
||||||
|
AppErrorCode.MISSING_SIGNATURE_FIELD,
|
||||||
AppErrorCode.CSC_INSTANCE_MODE_MISMATCH,
|
AppErrorCode.CSC_INSTANCE_MODE_MISMATCH,
|
||||||
AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY,
|
AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY,
|
||||||
AppErrorCode.CSC_CERT_INVALID,
|
AppErrorCode.CSC_CERT_INVALID,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emai
|
|||||||
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email';
|
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email';
|
||||||
import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email';
|
import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email';
|
||||||
import { ADMIN_DELETE_ORGANISATION_JOB_DEFINITION } from './definitions/internal/admin-delete-organisation';
|
import { ADMIN_DELETE_ORGANISATION_JOB_DEFINITION } from './definitions/internal/admin-delete-organisation';
|
||||||
|
import { ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION } from './definitions/internal/alert-organisation-seat-drift';
|
||||||
import { BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION } from './definitions/internal/backport-subscription-claims';
|
import { BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION } from './definitions/internal/backport-subscription-claims';
|
||||||
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
|
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
|
||||||
import { CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION } from './definitions/internal/cancel-organisation-subscription';
|
import { CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION } from './definitions/internal/cancel-organisation-subscription';
|
||||||
@@ -29,6 +30,7 @@ import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-docume
|
|||||||
import { SEAL_DOCUMENT_SWEEP_JOB_DEFINITION } from './definitions/internal/seal-document-sweep';
|
import { SEAL_DOCUMENT_SWEEP_JOB_DEFINITION } from './definitions/internal/seal-document-sweep';
|
||||||
import { SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION } from './definitions/internal/send-signing-reminders-sweep';
|
import { SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION } from './definitions/internal/send-signing-reminders-sweep';
|
||||||
import { SYNC_EMAIL_DOMAINS_JOB_DEFINITION } from './definitions/internal/sync-email-domains';
|
import { SYNC_EMAIL_DOMAINS_JOB_DEFINITION } from './definitions/internal/sync-email-domains';
|
||||||
|
import { SYNC_ORGANISATION_SEATS_JOB_DEFINITION } from './definitions/internal/sync-organisation-seats';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `as const` assertion is load bearing as it provides the correct level of type inference for
|
* The `as const` assertion is load bearing as it provides the correct level of type inference for
|
||||||
@@ -64,7 +66,9 @@ export const jobsClient = new JobClient([
|
|||||||
CLEANUP_RATE_LIMITS_JOB_DEFINITION,
|
CLEANUP_RATE_LIMITS_JOB_DEFINITION,
|
||||||
SYNC_EMAIL_DOMAINS_JOB_DEFINITION,
|
SYNC_EMAIL_DOMAINS_JOB_DEFINITION,
|
||||||
ADMIN_DELETE_ORGANISATION_JOB_DEFINITION,
|
ADMIN_DELETE_ORGANISATION_JOB_DEFINITION,
|
||||||
|
ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION,
|
||||||
CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION,
|
CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION,
|
||||||
|
SYNC_ORGANISATION_SEATS_JOB_DEFINITION,
|
||||||
] as const);
|
] as const);
|
||||||
|
|
||||||
export const jobs = jobsClient;
|
export const jobs = jobsClient;
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { mailer } from '@documenso/email/mailer';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { IS_BILLING_ENABLED, SUPPORT_EMAIL } from '../../../constants/app';
|
||||||
|
import { DOCUMENSO_INTERNAL_EMAIL } from '../../../constants/email';
|
||||||
|
import type { JobRunIO } from '../../client/_internal/job';
|
||||||
|
import type { TAlertOrganisationSeatDriftJobDefinition } from './alert-organisation-seat-drift';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daily check for organisations whose member count exceeds their paid seat
|
||||||
|
* count (`organisationClaim.memberCount`, where `0` means unlimited).
|
||||||
|
*/
|
||||||
|
export const run = async ({ io }: { payload: TAlertOrganisationSeatDriftJobDefinition; io: JobRunIO }) => {
|
||||||
|
if (!IS_BILLING_ENABLED()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const organisations = await prisma.organisation.findMany({
|
||||||
|
where: {
|
||||||
|
// Exclude unlimited-seat plans (memberCount === 0).
|
||||||
|
organisationClaim: {
|
||||||
|
memberCount: {
|
||||||
|
not: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
organisationClaim: {
|
||||||
|
select: {
|
||||||
|
memberCount: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
members: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const driftedOrganisations = organisations.filter(
|
||||||
|
(organisation) =>
|
||||||
|
organisation.organisationClaim !== null &&
|
||||||
|
organisation._count.members > organisation.organisationClaim.memberCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (driftedOrganisations.length === 0) {
|
||||||
|
io.logger.info('No organisations exceed their paid seat count');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await mailer.sendMail({
|
||||||
|
to: SUPPORT_EMAIL,
|
||||||
|
from: DOCUMENSO_INTERNAL_EMAIL,
|
||||||
|
subject: `[Billing] ${driftedOrganisations.length} organisation(s) exceed their paid seat count`,
|
||||||
|
text: [
|
||||||
|
`${driftedOrganisations.length} organisation(s) have more members than their paid seat count:`,
|
||||||
|
'',
|
||||||
|
...driftedOrganisations.map(
|
||||||
|
(organisation) =>
|
||||||
|
`- ${organisation.name} (${organisation.id}): ${organisation._count.members} members vs ${organisation.organisationClaim?.memberCount ?? 0} paid seats`,
|
||||||
|
),
|
||||||
|
].join('\n'),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type { JobDefinition } from '../../client/_internal/job';
|
||||||
|
|
||||||
|
const ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_ID = 'internal.alert-organisation-seat-drift';
|
||||||
|
|
||||||
|
const ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_SCHEMA = z.object({});
|
||||||
|
|
||||||
|
export type TAlertOrganisationSeatDriftJobDefinition = z.infer<
|
||||||
|
typeof ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_SCHEMA
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION = {
|
||||||
|
id: ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_ID,
|
||||||
|
name: 'Alert Organisation Seat Drift',
|
||||||
|
version: '1.0.0',
|
||||||
|
trigger: {
|
||||||
|
name: ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_ID,
|
||||||
|
schema: ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_SCHEMA,
|
||||||
|
cron: '0 0 * * *', // Once a day at midnight.
|
||||||
|
},
|
||||||
|
handler: async ({ payload, io }) => {
|
||||||
|
const handler = await import('./alert-organisation-seat-drift.handler');
|
||||||
|
|
||||||
|
await handler.run({ payload, io });
|
||||||
|
},
|
||||||
|
} as const satisfies JobDefinition<
|
||||||
|
typeof ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_ID,
|
||||||
|
TAlertOrganisationSeatDriftJobDefinition
|
||||||
|
>;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { SubscriptionStatus } from '@prisma/client';
|
||||||
|
import { IS_BILLING_ENABLED } from '../../../constants/app';
|
||||||
|
import type { JobRunIO } from '../../client/_internal/job';
|
||||||
|
import type { TSyncOrganisationSeatsJobDefinition } from './sync-organisation-seats';
|
||||||
|
|
||||||
|
export const run = async ({ payload }: { payload: TSyncOrganisationSeatsJobDefinition; io: JobRunIO }) => {
|
||||||
|
const { organisationId } = payload;
|
||||||
|
|
||||||
|
if (!IS_BILLING_ENABLED()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const organisation = await prisma.organisation.findUnique({
|
||||||
|
where: {
|
||||||
|
id: organisationId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
subscription: true,
|
||||||
|
organisationClaim: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!organisation || !organisation.subscription) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip canceled/terminal subscriptions — Stripe rejects quantity updates on a
|
||||||
|
// canceled subscription. PAST_DUE is still live and a no-proration shrink is
|
||||||
|
// safe, so it's allowed through.
|
||||||
|
if (organisation.subscription.status === SubscriptionStatus.INACTIVE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberCount = await prisma.organisationMember.count({
|
||||||
|
where: {
|
||||||
|
organisationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// An organisation always retains its owner; guarding zero avoids writing the
|
||||||
|
// unlimited sentinel to the claim.
|
||||||
|
if (memberCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncMemberCountWithStripeSeatPlan(
|
||||||
|
organisation.subscription,
|
||||||
|
organisation.organisationClaim,
|
||||||
|
memberCount,
|
||||||
|
'shrink',
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type { JobDefinition } from '../../client/_internal/job';
|
||||||
|
|
||||||
|
const SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID = 'internal.sync-organisation-seats';
|
||||||
|
|
||||||
|
const SYNC_ORGANISATION_SEATS_JOB_DEFINITION_SCHEMA = z.object({
|
||||||
|
organisationId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TSyncOrganisationSeatsJobDefinition = z.infer<typeof SYNC_ORGANISATION_SEATS_JOB_DEFINITION_SCHEMA>;
|
||||||
|
|
||||||
|
export const SYNC_ORGANISATION_SEATS_JOB_DEFINITION = {
|
||||||
|
id: SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID,
|
||||||
|
name: 'Sync Organisation Seats',
|
||||||
|
version: '1.0.0',
|
||||||
|
trigger: {
|
||||||
|
name: SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID,
|
||||||
|
schema: SYNC_ORGANISATION_SEATS_JOB_DEFINITION_SCHEMA,
|
||||||
|
},
|
||||||
|
handler: async ({ payload, io }) => {
|
||||||
|
const handler = await import('./sync-organisation-seats.handler');
|
||||||
|
|
||||||
|
await handler.run({ payload, io });
|
||||||
|
},
|
||||||
|
} as const satisfies JobDefinition<
|
||||||
|
typeof SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID,
|
||||||
|
TSyncOrganisationSeatsJobDefinition
|
||||||
|
>;
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { EnvelopeType } from '@prisma/client';
|
||||||
|
|
||||||
|
export const ADMIN_SEARCH_RESULTS_PER_TYPE = 5;
|
||||||
|
|
||||||
|
const MAX_POSTGRES_INT = 2147483647;
|
||||||
|
|
||||||
|
const GROUP_ORDER = ['document', 'user', 'organisation', 'team', 'recipient', 'subscription'] as const;
|
||||||
|
|
||||||
|
export type AdminGlobalSearchResultType = (typeof GROUP_ORDER)[number];
|
||||||
|
|
||||||
|
export type AdminGlobalSearchResult = {
|
||||||
|
label: string;
|
||||||
|
sublabel?: string;
|
||||||
|
path: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminGlobalSearchGroup = {
|
||||||
|
type: AdminGlobalSearchResultType;
|
||||||
|
results: AdminGlobalSearchResult[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminGlobalSearchOptions = {
|
||||||
|
query: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PartialResults = Partial<Record<AdminGlobalSearchResultType, AdminGlobalSearchResult[]>>;
|
||||||
|
|
||||||
|
export const adminGlobalSearch = async ({ query }: AdminGlobalSearchOptions): Promise<AdminGlobalSearchGroup[]> => {
|
||||||
|
const trimmedQuery = query.trim();
|
||||||
|
|
||||||
|
if (trimmedQuery.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultsByType = await resolveSearch(trimmedQuery);
|
||||||
|
|
||||||
|
return GROUP_ORDER.map((type) => ({
|
||||||
|
type,
|
||||||
|
results: (resultsByType[type] ?? []).map((result) => ({
|
||||||
|
...result,
|
||||||
|
// Append the raw query so cmdk's client-side filter never hides
|
||||||
|
// server-verified results.
|
||||||
|
value: `${result.value} ${trimmedQuery}`,
|
||||||
|
})),
|
||||||
|
})).filter((group) => group.results.length > 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveSearch = async (query: string): Promise<PartialResults> => {
|
||||||
|
// Recognized ID prefixes resolve to a single exact lookup.
|
||||||
|
if (query.startsWith('envelope_')) {
|
||||||
|
return { document: await findDocumentsByExactId({ id: query }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.startsWith('document_')) {
|
||||||
|
return { document: await findDocumentsByExactId({ secondaryId: query }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.startsWith('org_')) {
|
||||||
|
return { organisation: await findOrganisationsByIdOrUrl(query) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bare numbers are treated as verified ID lookups only. Oversized numbers
|
||||||
|
// fall through to text search.
|
||||||
|
const numericId = Number(query);
|
||||||
|
|
||||||
|
if (/^\d+$/.test(query) && numericId <= MAX_POSTGRES_INT) {
|
||||||
|
const [document, user, team, recipient, subscription] = await Promise.all([
|
||||||
|
findDocumentsByExactId({ secondaryId: `document_${numericId}` }),
|
||||||
|
findUsersById(numericId),
|
||||||
|
findTeamsById(numericId),
|
||||||
|
findRecipientsById(numericId),
|
||||||
|
findSubscriptionsById(numericId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { document, user, team, recipient, subscription };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Free text searches all resource types in parallel.
|
||||||
|
const [document, user, organisation, team, recipient, subscription] = await Promise.all([
|
||||||
|
findDocumentsByText(query),
|
||||||
|
findUsersByText(query),
|
||||||
|
findOrganisationsByText(query),
|
||||||
|
findTeamsByText(query),
|
||||||
|
findRecipientsByText(query),
|
||||||
|
findSubscriptionsByText(query),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
document,
|
||||||
|
user,
|
||||||
|
organisation,
|
||||||
|
team,
|
||||||
|
recipient,
|
||||||
|
subscription,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const joinSublabel = (parts: Array<string | null | undefined>) =>
|
||||||
|
parts.filter((part) => part && part.length > 0).join(' · ') || undefined;
|
||||||
|
|
||||||
|
// ─── Documents ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const documentSelect = {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
secondaryId: true,
|
||||||
|
user: { select: { email: true } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type DocumentRow = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
secondaryId: string;
|
||||||
|
user: { email: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapDocument = (envelope: DocumentRow): AdminGlobalSearchResult => ({
|
||||||
|
label: envelope.title,
|
||||||
|
sublabel: joinSublabel([envelope.secondaryId, envelope.user.email]),
|
||||||
|
path: `/admin/documents/${envelope.id}`,
|
||||||
|
value: `document ${envelope.id} ${envelope.secondaryId} ${envelope.title} ${envelope.user.email}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const findDocumentsByExactId = async (where: { id: string } | { secondaryId: string }) => {
|
||||||
|
const envelope = await prisma.envelope.findFirst({
|
||||||
|
where: { ...where, type: EnvelopeType.DOCUMENT },
|
||||||
|
select: documentSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
return envelope ? [mapDocument(envelope)] : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const findDocumentsByText = async (query: string) => {
|
||||||
|
const envelopes = await prisma.envelope.findMany({
|
||||||
|
where: {
|
||||||
|
type: EnvelopeType.DOCUMENT,
|
||||||
|
title: { contains: query, mode: 'insensitive' },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
|
||||||
|
select: documentSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
return envelopes.map(mapDocument);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Users ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const userSelect = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type UserRow = { id: number; name: string | null; email: string };
|
||||||
|
|
||||||
|
const mapUser = (user: UserRow): AdminGlobalSearchResult => ({
|
||||||
|
label: user.name || user.email,
|
||||||
|
sublabel: joinSublabel([`#${user.id}`, user.email]),
|
||||||
|
path: `/admin/users/${user.id}`,
|
||||||
|
value: `user ${user.id} ${user.name ?? ''} ${user.email}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const findUsersById = async (id: number) => {
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: { id },
|
||||||
|
select: userSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
return user ? [mapUser(user)] : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const findUsersByText = async (query: string) => {
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ name: { contains: query, mode: 'insensitive' } }, { email: { contains: query, mode: 'insensitive' } }],
|
||||||
|
},
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
|
||||||
|
select: userSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
return users.map(mapUser);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Organisations ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const organisationSelect = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
owner: { select: { email: true } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type OrganisationRow = { id: string; name: string; owner: { email: string } };
|
||||||
|
|
||||||
|
const mapOrganisation = (organisation: OrganisationRow): AdminGlobalSearchResult => ({
|
||||||
|
label: organisation.name,
|
||||||
|
sublabel: joinSublabel([organisation.id, organisation.owner.email]),
|
||||||
|
path: `/admin/organisations/${organisation.id}`,
|
||||||
|
value: `organisation ${organisation.id} ${organisation.name} ${organisation.owner.email}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const findOrganisationsByIdOrUrl = async (query: string) => {
|
||||||
|
const organisations = await prisma.organisation.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ id: query }, { url: query }],
|
||||||
|
},
|
||||||
|
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
|
||||||
|
select: organisationSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
return organisations.map(mapOrganisation);
|
||||||
|
};
|
||||||
|
|
||||||
|
const findOrganisationsByText = async (query: string) => {
|
||||||
|
const organisations = await prisma.organisation.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: query, mode: 'insensitive' } },
|
||||||
|
{ url: { contains: query, mode: 'insensitive' } },
|
||||||
|
{ customerId: { contains: query, mode: 'insensitive' } },
|
||||||
|
{ owner: { email: { contains: query, mode: 'insensitive' } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
|
||||||
|
select: organisationSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
return organisations.map(mapOrganisation);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Teams ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const teamSelect = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
url: true,
|
||||||
|
organisation: { select: { name: true } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type TeamRow = { id: number; name: string; url: string; organisation: { name: string } };
|
||||||
|
|
||||||
|
const mapTeam = (team: TeamRow): AdminGlobalSearchResult => ({
|
||||||
|
label: team.name,
|
||||||
|
sublabel: joinSublabel([`#${team.id}`, `/${team.url}`, team.organisation.name]),
|
||||||
|
path: `/admin/teams/${team.id}`,
|
||||||
|
value: `team ${team.id} ${team.name} ${team.url} ${team.organisation.name}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const findTeamsById = async (id: number) => {
|
||||||
|
const team = await prisma.team.findFirst({
|
||||||
|
where: { id },
|
||||||
|
select: teamSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
return team ? [mapTeam(team)] : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const findTeamsByText = async (query: string) => {
|
||||||
|
const teams = await prisma.team.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ name: { contains: query, mode: 'insensitive' } }, { url: { contains: query, mode: 'insensitive' } }],
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
|
||||||
|
select: teamSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
return teams.map(mapTeam);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Recipients ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const recipientSelect = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
envelope: { select: { id: true, title: true } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type RecipientRow = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
envelope: { id: string; title: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapRecipient = (recipient: RecipientRow): AdminGlobalSearchResult => ({
|
||||||
|
label: recipient.email,
|
||||||
|
sublabel: joinSublabel([`#${recipient.id}`, recipient.name, recipient.envelope.title]),
|
||||||
|
path: `/admin/documents/${recipient.envelope.id}`,
|
||||||
|
value: `recipient ${recipient.id} ${recipient.name} ${recipient.email} ${recipient.envelope.title}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const findRecipientsById = async (id: number) => {
|
||||||
|
const recipient = await prisma.recipient.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
envelope: { type: EnvelopeType.DOCUMENT },
|
||||||
|
},
|
||||||
|
select: recipientSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
return recipient ? [mapRecipient(recipient)] : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const findRecipientsByText = async (query: string) => {
|
||||||
|
const recipients = await prisma.recipient.findMany({
|
||||||
|
where: {
|
||||||
|
envelope: { type: EnvelopeType.DOCUMENT },
|
||||||
|
OR: [{ email: { contains: query, mode: 'insensitive' } }, { name: { contains: query, mode: 'insensitive' } }],
|
||||||
|
},
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
|
||||||
|
select: recipientSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
return recipients.map(mapRecipient);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Subscriptions ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const subscriptionSelect = {
|
||||||
|
id: true,
|
||||||
|
status: true,
|
||||||
|
planId: true,
|
||||||
|
customerId: true,
|
||||||
|
organisationId: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type SubscriptionRow = {
|
||||||
|
id: number;
|
||||||
|
status: string;
|
||||||
|
planId: string;
|
||||||
|
customerId: string;
|
||||||
|
organisationId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapSubscription = (subscription: SubscriptionRow): AdminGlobalSearchResult => ({
|
||||||
|
label: `Subscription #${subscription.id}`,
|
||||||
|
sublabel: joinSublabel([subscription.status, subscription.planId]),
|
||||||
|
path: `/admin/organisations/${subscription.organisationId}`,
|
||||||
|
value: `subscription ${subscription.id} ${subscription.planId} ${subscription.customerId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const findSubscriptionsById = async (id: number) => {
|
||||||
|
const subscription = await prisma.subscription.findFirst({
|
||||||
|
where: { id },
|
||||||
|
select: subscriptionSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
return subscription ? [mapSubscription(subscription)] : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const findSubscriptionsByText = async (query: string) => {
|
||||||
|
const subscriptions = await prisma.subscription.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ planId: { contains: query, mode: 'insensitive' } },
|
||||||
|
{ customerId: { contains: query, mode: 'insensitive' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
|
||||||
|
select: subscriptionSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
return subscriptions.map(mapSubscription);
|
||||||
|
};
|
||||||
@@ -13,7 +13,7 @@ export const getDocumentStats = async () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const stats: Record<Exclude<ExtendedDocumentStatus, 'INBOX'>, number> = {
|
const stats: Record<Exclude<ExtendedDocumentStatus, 'INBOX' | 'EXPIRED'>, number> = {
|
||||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||||
[ExtendedDocumentStatus.PENDING]: 0,
|
[ExtendedDocumentStatus.PENDING]: 0,
|
||||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { match } from 'ts-pattern';
|
|||||||
|
|
||||||
import type { FindResultResponse } from '../../types/search-params';
|
import type { FindResultResponse } from '../../types/search-params';
|
||||||
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
||||||
|
import { hasExpiredRecipient } from '../envelope/query-helpers';
|
||||||
import { getTeamById } from '../team/get-team';
|
import { getTeamById } from '../team/get-team';
|
||||||
|
|
||||||
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
|
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
|
||||||
@@ -36,6 +37,11 @@ export type FindDocumentsOptions = {
|
|||||||
senderIds?: number[];
|
senderIds?: number[];
|
||||||
query?: string;
|
query?: string;
|
||||||
folderId?: string;
|
folderId?: string;
|
||||||
|
/**
|
||||||
|
* When true, restrict results to envelopes with at least one recipient whose signing
|
||||||
|
* link has expired. Orthogonal to `status` — applied additively.
|
||||||
|
*/
|
||||||
|
hasExpiredRecipients?: boolean;
|
||||||
/**
|
/**
|
||||||
* When true (default), use a windowed count that caps early for faster pagination.
|
* When true (default), use a windowed count that caps early for faster pagination.
|
||||||
* When false, use a full COUNT(*) for exact totals — preferred for external API consumers.
|
* When false, use a full COUNT(*) for exact totals — preferred for external API consumers.
|
||||||
@@ -115,6 +121,7 @@ export const findDocuments = async ({
|
|||||||
senderIds,
|
senderIds,
|
||||||
query = '',
|
query = '',
|
||||||
folderId,
|
folderId,
|
||||||
|
hasExpiredRecipients,
|
||||||
useWindowedCount = true,
|
useWindowedCount = true,
|
||||||
}: FindDocumentsOptions) => {
|
}: FindDocumentsOptions) => {
|
||||||
const user = await prisma.user.findFirstOrThrow({
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
@@ -199,6 +206,11 @@ export const findDocuments = async ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expired recipient filter (orthogonal to status, additive)
|
||||||
|
if (hasExpiredRecipients) {
|
||||||
|
qb = qb.where((eb) => hasExpiredRecipient(eb));
|
||||||
|
}
|
||||||
|
|
||||||
return qb;
|
return qb;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -305,6 +317,15 @@ export const findDocuments = async ({
|
|||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
.with(ExtendedDocumentStatus.EXPIRED, () =>
|
||||||
|
qb.where((eb) =>
|
||||||
|
eb.and([
|
||||||
|
personalDeletedFilter(eb),
|
||||||
|
hasExpiredRecipient(eb),
|
||||||
|
eb.or([eb('Envelope.userId', '=', user.id), recipientExists(eb, user.email)]),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)
|
||||||
.exhaustive();
|
.exhaustive();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -455,6 +476,18 @@ export const findDocuments = async ({
|
|||||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.with(ExtendedDocumentStatus.EXPIRED, () =>
|
||||||
|
qb.where((eb) => {
|
||||||
|
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||||
|
|
||||||
|
if (teamEmail) {
|
||||||
|
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||||
|
accessBranches.push(recipientExists(eb, teamEmail));
|
||||||
|
}
|
||||||
|
|
||||||
|
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), hasExpiredRecipient(eb), eb.or(accessBranches)]);
|
||||||
|
}),
|
||||||
|
)
|
||||||
.exhaustive();
|
.exhaustive();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { DateTime } from 'luxon';
|
|||||||
|
|
||||||
import { STATS_COUNT_CAP } from '../../constants/document';
|
import { STATS_COUNT_CAP } from '../../constants/document';
|
||||||
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||||
|
import { hasExpiredRecipient } from '../envelope/query-helpers';
|
||||||
import { getTeamById } from '../team/get-team';
|
import { getTeamById } from '../team/get-team';
|
||||||
|
|
||||||
// Kysely query builder type for Envelope queries.
|
// Kysely query builder type for Envelope queries.
|
||||||
@@ -253,6 +254,19 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
|
|||||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// EXPIRED: docs visible to the team/user with at least one expired, unsigned recipient.
|
||||||
|
// Access control mirrors the EXPIRED branch in findDocuments so the count matches the listing.
|
||||||
|
const expiredQuery = buildBaseQuery().where((eb) => {
|
||||||
|
const accessBranches = [eb('Envelope.teamId', '=', team.id)];
|
||||||
|
|
||||||
|
if (teamEmail) {
|
||||||
|
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||||
|
accessBranches.push(recipientExists(eb, teamEmail));
|
||||||
|
}
|
||||||
|
|
||||||
|
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), hasExpiredRecipient(eb), eb.or(accessBranches)]);
|
||||||
|
});
|
||||||
|
|
||||||
// INBOX: non-draft docs where team email is a NOT_SIGNED, non-CC recipient
|
// INBOX: non-draft docs where team email is a NOT_SIGNED, non-CC recipient
|
||||||
// Returns 0 if the team has no team email.
|
// Returns 0 if the team has no team email.
|
||||||
const inboxQuery = teamEmail
|
const inboxQuery = teamEmail
|
||||||
@@ -274,15 +288,17 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
|
|||||||
|
|
||||||
// ─── Execute all counts in parallel ──────────────────────────────────
|
// ─── Execute all counts in parallel ──────────────────────────────────
|
||||||
|
|
||||||
const [draft, pending, completed, rejected, cancelled, inbox] = await Promise.all([
|
const [draft, pending, completed, rejected, cancelled, expired, inbox] = await Promise.all([
|
||||||
cappedCount(draftQuery),
|
cappedCount(draftQuery),
|
||||||
cappedCount(pendingQuery),
|
cappedCount(pendingQuery),
|
||||||
cappedCount(completedQuery),
|
cappedCount(completedQuery),
|
||||||
cappedCount(rejectedQuery),
|
cappedCount(rejectedQuery),
|
||||||
cappedCount(cancelledQuery),
|
cappedCount(cancelledQuery),
|
||||||
|
cappedCount(expiredQuery),
|
||||||
inboxQuery ? cappedCount(inboxQuery) : Promise.resolve(0),
|
inboxQuery ? cappedCount(inboxQuery) : Promise.resolve(0),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// `expired` is intentionally excluded from `all` — it overlaps PENDING.
|
||||||
const all = Math.min(draft + pending + completed + rejected + cancelled + inbox, STATS_COUNT_CAP);
|
const all = Math.min(draft + pending + completed + rejected + cancelled + inbox, STATS_COUNT_CAP);
|
||||||
|
|
||||||
const stats: Record<ExtendedDocumentStatus, number> = {
|
const stats: Record<ExtendedDocumentStatus, number> = {
|
||||||
@@ -291,6 +307,7 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
|
|||||||
[ExtendedDocumentStatus.COMPLETED]: completed,
|
[ExtendedDocumentStatus.COMPLETED]: completed,
|
||||||
[ExtendedDocumentStatus.REJECTED]: rejected,
|
[ExtendedDocumentStatus.REJECTED]: rejected,
|
||||||
[ExtendedDocumentStatus.CANCELLED]: cancelled,
|
[ExtendedDocumentStatus.CANCELLED]: cancelled,
|
||||||
|
[ExtendedDocumentStatus.EXPIRED]: expired,
|
||||||
[ExtendedDocumentStatus.INBOX]: inbox,
|
[ExtendedDocumentStatus.INBOX]: inbox,
|
||||||
[ExtendedDocumentStatus.ALL]: all,
|
[ExtendedDocumentStatus.ALL]: all,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
|
|||||||
.map((r) => (r.name ? `${r.name} (${r.email}, id: ${r.id})` : `${r.email} (id: ${r.id})`))
|
.map((r) => (r.name ? `${r.name} (${r.email}, id: ${r.id})` : `${r.email} (id: ${r.id})`))
|
||||||
.join(', ');
|
.join(', ');
|
||||||
|
|
||||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, {
|
||||||
message: `The following recipients are missing required fields: ${missingRecipientDescriptions}. Signers must have at least one signature field.`,
|
message: `The following recipients are missing required fields: ${missingRecipientDescriptions}. Signers must have at least one signature field.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
|||||||
import type { FindResultResponse } from '../../types/search-params';
|
import type { FindResultResponse } from '../../types/search-params';
|
||||||
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
||||||
import { getTeamById } from '../team/get-team';
|
import { getTeamById } from '../team/get-team';
|
||||||
|
import { hasExpiredRecipient } from './query-helpers';
|
||||||
|
|
||||||
export type FindEnvelopesOptions = {
|
export type FindEnvelopesOptions = {
|
||||||
userId: number;
|
userId: number;
|
||||||
@@ -23,6 +24,11 @@ export type FindEnvelopesOptions = {
|
|||||||
};
|
};
|
||||||
query?: string;
|
query?: string;
|
||||||
folderId?: string;
|
folderId?: string;
|
||||||
|
/**
|
||||||
|
* When true, restrict results to envelopes with at least one recipient whose signing
|
||||||
|
* link has expired. Orthogonal to `status` — applied additively.
|
||||||
|
*/
|
||||||
|
hasExpiredRecipients?: boolean;
|
||||||
/**
|
/**
|
||||||
* When true (default), use a windowed count that caps early for faster pagination.
|
* When true (default), use a windowed count that caps early for faster pagination.
|
||||||
* When false, use a full COUNT(*) for exact totals — preferred for external API consumers.
|
* When false, use a full COUNT(*) for exact totals — preferred for external API consumers.
|
||||||
@@ -106,6 +112,7 @@ export const findEnvelopes = async ({
|
|||||||
orderBy,
|
orderBy,
|
||||||
query = '',
|
query = '',
|
||||||
folderId,
|
folderId,
|
||||||
|
hasExpiredRecipients,
|
||||||
useWindowedCount = true,
|
useWindowedCount = true,
|
||||||
}: FindEnvelopesOptions) => {
|
}: FindEnvelopesOptions) => {
|
||||||
const user = await prisma.user.findFirstOrThrow({
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
@@ -182,6 +189,11 @@ export const findEnvelopes = async ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expired recipient filter (orthogonal to status, additive)
|
||||||
|
if (hasExpiredRecipients) {
|
||||||
|
qb = qb.where((eb) => hasExpiredRecipient(eb));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Access control ──────────────────────────────────────────────────
|
// ─── Access control ──────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// An envelope is visible if ANY of:
|
// An envelope is visible if ANY of:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { match } from 'ts-pattern';
|
|||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
import { DocumentAccessAuth, type TDocumentAuthMethods } from '../../types/document-auth';
|
import { DocumentAccessAuth, type TDocumentAuthMethods } from '../../types/document-auth';
|
||||||
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||||
|
import { getRecipientsWithMissingFields } from '../../utils/recipients';
|
||||||
import { extractFieldAutoInsertValues } from '../document/send-document';
|
import { extractFieldAutoInsertValues } from '../document/send-document';
|
||||||
import { getTeamSettings } from '../team/get-team-settings';
|
import { getTeamSettings } from '../team/get-team-settings';
|
||||||
import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
|
import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
|
||||||
@@ -125,6 +126,17 @@ export const getEnvelopeForDirectTemplateSigning = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const recipientsWithMissingFields = getRecipientsWithMissingFields(
|
||||||
|
envelope.recipients,
|
||||||
|
envelope.recipients.flatMap((envelopeRecipient) => envelopeRecipient.fields),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (recipientsWithMissingFields.length > 0) {
|
||||||
|
throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, {
|
||||||
|
message: 'One or more signers on this direct template are missing a signature field',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const settings = await getTeamSettings({ teamId: envelope.teamId });
|
const settings = await getTeamSettings({ teamId: envelope.teamId });
|
||||||
|
|
||||||
const sender = settings.includeSenderDetails
|
const sender = settings.includeSenderDetails
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import EnvelopeSchema from '@documenso/prisma/generated/zod/modelSchema/Envelope
|
|||||||
import SignatureSchema from '@documenso/prisma/generated/zod/modelSchema/SignatureSchema';
|
import SignatureSchema from '@documenso/prisma/generated/zod/modelSchema/SignatureSchema';
|
||||||
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
|
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
|
||||||
import UserSchema from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
|
import UserSchema from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
|
||||||
import { DocumentSigningOrder, DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
|
import { DocumentSigningOrder, DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
@@ -266,6 +266,11 @@ export const getEnvelopeForRecipientSigning = async ({
|
|||||||
|
|
||||||
if (envelope.documentMeta.signingOrder === DocumentSigningOrder.SEQUENTIAL && currentRecipientIndex !== -1) {
|
if (envelope.documentMeta.signingOrder === DocumentSigningOrder.SEQUENTIAL && currentRecipientIndex !== -1) {
|
||||||
for (let i = 0; i < currentRecipientIndex; i++) {
|
for (let i = 0; i < currentRecipientIndex; i++) {
|
||||||
|
// CC recipients have no action to take, so they can never block the flow.
|
||||||
|
if (envelope.recipients[i].role === RecipientRole.CC) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (envelope.recipients[i].signingStatus !== SigningStatus.SIGNED) {
|
if (envelope.recipients[i].signingStatus !== SigningStatus.SIGNED) {
|
||||||
isRecipientsTurn = false;
|
isRecipientsTurn = false;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { sql } from '@documenso/prisma';
|
||||||
|
import type { DB } from '@documenso/prisma/generated/types';
|
||||||
|
import { RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
|
import type { ExpressionBuilder } from 'kysely';
|
||||||
|
|
||||||
|
// Expression builder type scoped to the Envelope table context.
|
||||||
|
type EnvelopeExpressionBuilder = ExpressionBuilder<DB, 'Envelope'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable EXISTS subquery: checks that the envelope has at least one recipient whose
|
||||||
|
* signing link has expired — `expiresAt` in the past, still unsigned, and not a CC.
|
||||||
|
*
|
||||||
|
* This is the single source of truth for the "expired recipient" predicate used by
|
||||||
|
* `findDocuments`, `findEnvelopes`, and `getStats`. It must stay in sync with
|
||||||
|
* `isRecipientExpired` (packages/lib/utils/recipients.ts).
|
||||||
|
*/
|
||||||
|
export const hasExpiredRecipient = (eb: EnvelopeExpressionBuilder) =>
|
||||||
|
eb.exists(
|
||||||
|
eb
|
||||||
|
.selectFrom('Recipient')
|
||||||
|
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
|
||||||
|
.where('Recipient.expiresAt', 'is not', null)
|
||||||
|
.where('Recipient.expiresAt', '<=', new Date())
|
||||||
|
.where('Recipient.signingStatus', '=', sql.lit(SigningStatus.NOT_SIGNED))
|
||||||
|
.where('Recipient.role', '!=', sql.lit(RecipientRole.CC))
|
||||||
|
.select(sql.lit(1).as('one')),
|
||||||
|
);
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
|
import {
|
||||||
|
assertMemberCountWithinCap,
|
||||||
|
syncMemberCountWithStripeSeatPlan,
|
||||||
|
} from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import type { OrganisationGroup, OrganisationMemberRole } from '@prisma/client';
|
import type { OrganisationGroup, OrganisationMemberRole } from '@prisma/client';
|
||||||
import { OrganisationGroupType, OrganisationMemberInviteStatus } from '@prisma/client';
|
import { OrganisationGroupType, OrganisationMemberInviteStatus, SubscriptionStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
import { IS_BILLING_ENABLED } from '../../constants/app';
|
||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
import { jobs } from '../../jobs/client';
|
import { jobs } from '../../jobs/client';
|
||||||
import { generateDatabaseId } from '../../universal/id';
|
import { generateDatabaseId } from '../../universal/id';
|
||||||
@@ -22,6 +27,13 @@ export const acceptOrganisationInvitation = async ({ token }: AcceptOrganisation
|
|||||||
organisation: {
|
organisation: {
|
||||||
include: {
|
include: {
|
||||||
groups: true,
|
groups: true,
|
||||||
|
organisationClaim: true,
|
||||||
|
subscription: true,
|
||||||
|
members: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -66,6 +78,35 @@ export const acceptOrganisationInvitation = async ({ token }: AcceptOrganisation
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newMemberCount = organisation.members.length + 1;
|
||||||
|
|
||||||
|
// Billing occurs when a user accepts an invite.
|
||||||
|
// Assert that the new member count is within the cap and sync the seat plan with Stripe.
|
||||||
|
if (IS_BILLING_ENABLED()) {
|
||||||
|
const { subscription, organisationClaim } = organisation;
|
||||||
|
|
||||||
|
// A canceled subscription cannot have its seat quantity updated in Stripe,
|
||||||
|
// and an organisation with lapsed billing should not gain new members.
|
||||||
|
// Throw a deliberate error so the invite page can render an accurate
|
||||||
|
// message instead of an opaque Stripe failure.
|
||||||
|
if (subscription && subscription.status === SubscriptionStatus.INACTIVE) {
|
||||||
|
throw new AppError('SUBSCRIPTION_INACTIVE', {
|
||||||
|
message: 'The organisation subscription is inactive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Organisations can exist without a subscription (e.g. after being
|
||||||
|
// downgraded to the free plan). The claim cap remains authoritative in
|
||||||
|
// that case, surfacing LIMIT_EXCEEDED instead of an opaque "subscription
|
||||||
|
// not found" error.
|
||||||
|
await assertMemberCountWithinCap(subscription, organisationClaim, newMemberCount);
|
||||||
|
|
||||||
|
if (subscription) {
|
||||||
|
await syncMemberCountWithStripeSeatPlan(subscription, organisationClaim, newMemberCount, 'grow');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Todo: Logging
|
||||||
await addUserToOrganisation({
|
await addUserToOrganisation({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
organisationId: organisation.id,
|
organisationId: organisation.id,
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
import {
|
|
||||||
assertMemberCountWithinCap,
|
|
||||||
syncMemberCountWithStripeSeatPlan,
|
|
||||||
} from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
|
||||||
import { OrganisationInviteEmailTemplate } from '@documenso/email/templates/organisation-invite';
|
import { OrganisationInviteEmailTemplate } from '@documenso/email/templates/organisation-invite';
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||||
@@ -17,7 +13,6 @@ import { createElement } from 'react';
|
|||||||
|
|
||||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||||
import { generateDatabaseId } from '../../universal/id';
|
import { generateDatabaseId } from '../../universal/id';
|
||||||
import { validateIfSubscriptionIsRequired } from '../../utils/billing';
|
|
||||||
import { buildOrganisationWhereQuery } from '../../utils/organisations';
|
import { buildOrganisationWhereQuery } from '../../utils/organisations';
|
||||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||||
import { getEmailContext } from '../email/get-email-context';
|
import { getEmailContext } from '../email/get-email-context';
|
||||||
@@ -62,8 +57,6 @@ export const createOrganisationMemberInvites = async ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
organisationGlobalSettings: true,
|
organisationGlobalSettings: true,
|
||||||
organisationClaim: true,
|
|
||||||
subscription: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,10 +64,6 @@ export const createOrganisationMemberInvites = async ({
|
|||||||
throw new AppError(AppErrorCode.NOT_FOUND);
|
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { organisationClaim } = organisation;
|
|
||||||
|
|
||||||
const subscription = validateIfSubscriptionIsRequired(organisation.subscription);
|
|
||||||
|
|
||||||
const currentOrganisationMemberRole = await getMemberOrganisationRole({
|
const currentOrganisationMemberRole = await getMemberOrganisationRole({
|
||||||
organisationId: organisation.id,
|
organisationId: organisation.id,
|
||||||
reference: {
|
reference: {
|
||||||
@@ -120,19 +109,6 @@ export const createOrganisationMemberInvites = async ({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const numberOfCurrentMembers = organisation.members.length;
|
|
||||||
const numberOfCurrentInvites = organisation.invites.length;
|
|
||||||
const numberOfNewInvites = organisationMemberInvites.length;
|
|
||||||
|
|
||||||
const totalMemberCountWithInvites = numberOfCurrentMembers + numberOfCurrentInvites + numberOfNewInvites;
|
|
||||||
|
|
||||||
// Enforce the seat cap and sync billing for seat based plans.
|
|
||||||
if (subscription) {
|
|
||||||
await assertMemberCountWithinCap(subscription, organisationClaim, totalMemberCountWithInvites);
|
|
||||||
|
|
||||||
await syncMemberCountWithStripeSeatPlan(subscription, organisationClaim, totalMemberCountWithInvites);
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.organisationMemberInvite.createMany({
|
await prisma.organisationMemberInvite.createMany({
|
||||||
data: organisationMemberInvites,
|
data: organisationMemberInvites,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -84,13 +84,13 @@ export const syncSubscriptionRateLimit = createRateLimit({
|
|||||||
|
|
||||||
export const apiV1RateLimit = createRateLimit({
|
export const apiV1RateLimit = createRateLimit({
|
||||||
action: 'api.v1',
|
action: 'api.v1',
|
||||||
max: 100,
|
max: 1000,
|
||||||
window: '1m',
|
window: '1m',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiV2RateLimit = createRateLimit({
|
export const apiV2RateLimit = createRateLimit({
|
||||||
action: 'api.v2',
|
action: 'api.v2',
|
||||||
max: 100,
|
max: 1000,
|
||||||
window: '1m',
|
window: '1m',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { DocumentSigningOrder, EnvelopeType, SigningStatus } from '@prisma/client';
|
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
|
|
||||||
export type GetIsRecipientTurnOptions = {
|
export type GetIsRecipientTurnOptions = {
|
||||||
token: string;
|
token: string;
|
||||||
@@ -38,6 +38,11 @@ export async function getIsRecipientsTurnToSign({ token }: GetIsRecipientTurnOpt
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < currentRecipientIndex; i++) {
|
for (let i = 0; i < currentRecipientIndex; i++) {
|
||||||
|
// CC recipients have no action to take, so they can never block the flow.
|
||||||
|
if (recipients[i].role === RecipientRole.CC) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (recipients[i].signingStatus !== SigningStatus.SIGNED) {
|
if (recipients[i].signingStatus !== SigningStatus.SIGNED) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { EnvelopeType } from '@prisma/client';
|
import { EnvelopeType, RecipientRole } from '@prisma/client';
|
||||||
|
|
||||||
import { mapDocumentIdToSecondaryId } from '../../utils/envelope';
|
import { mapDocumentIdToSecondaryId } from '../../utils/envelope';
|
||||||
|
|
||||||
@@ -16,6 +16,11 @@ export const getNextPendingRecipient = async ({
|
|||||||
type: EnvelopeType.DOCUMENT,
|
type: EnvelopeType.DOCUMENT,
|
||||||
secondaryId: mapDocumentIdToSecondaryId(documentId),
|
secondaryId: mapDocumentIdToSecondaryId(documentId),
|
||||||
},
|
},
|
||||||
|
// CC recipients are informational only and never take part in signing,
|
||||||
|
// so they must never be offered as the next pending recipient.
|
||||||
|
role: {
|
||||||
|
not: RecipientRole.CC,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
orderBy: [
|
orderBy: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
extractDocumentAuthMethods,
|
extractDocumentAuthMethods,
|
||||||
} from '../../utils/document-auth';
|
} from '../../utils/document-auth';
|
||||||
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
|
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
|
||||||
|
import { getRecipientsWithMissingFields } from '../../utils/recipients';
|
||||||
import { sendDocument } from '../document/send-document';
|
import { sendDocument } from '../document/send-document';
|
||||||
import { validateFieldAuth } from '../document/validate-field-auth';
|
import { validateFieldAuth } from '../document/validate-field-auth';
|
||||||
import { incrementDocumentId } from '../envelope/increment-id';
|
import { incrementDocumentId } from '../envelope/increment-id';
|
||||||
@@ -172,6 +173,17 @@ export const createDocumentFromDirectTemplate = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const recipientsWithMissingFields = getRecipientsWithMissingFields(
|
||||||
|
recipients,
|
||||||
|
recipients.flatMap((recipient) => recipient.fields),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (recipientsWithMissingFields.length > 0) {
|
||||||
|
throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, {
|
||||||
|
message: 'One or more signers on this direct template are missing a signature field',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (directTemplateEnvelope.updatedAt.getTime() !== templateUpdatedAt.getTime()) {
|
if (directTemplateEnvelope.updatedAt.getTime() !== templateUpdatedAt.getTime()) {
|
||||||
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Template no longer matches' });
|
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Template no longer matches' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
|
import { jobs } from '../../jobs/client';
|
||||||
import { deleteOrganisation } from '../organisation/delete-organisation';
|
import { deleteOrganisation } from '../organisation/delete-organisation';
|
||||||
|
|
||||||
export type DeleteUserOptions = {
|
export type DeleteUserOptions = {
|
||||||
@@ -59,6 +60,13 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Organisations the user is a member of (but not owner). Owned organisations
|
||||||
|
// are fully torn down below (including subscription cancellation), so only
|
||||||
|
// these need a seat sync after the user's memberships cascade away.
|
||||||
|
const memberOrganisationIds = user.organisationMember
|
||||||
|
.filter((member) => member.organisation.ownerUserId !== user.id)
|
||||||
|
.map((member) => member.organisationId);
|
||||||
|
|
||||||
// For organisations the user owns - fully tear them down (orphan envelopes,
|
// For organisations the user owns - fully tear them down (orphan envelopes,
|
||||||
// delete the organisation, and cancel any Stripe subscription). Without this
|
// delete the organisation, and cancel any Stripe subscription). Without this
|
||||||
// the organisations would only cascade away when the user row is deleted,
|
// the organisations would only cascade away when the user row is deleted,
|
||||||
@@ -82,9 +90,21 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return await prisma.user.delete({
|
const deletedUser = await prisma.user.delete({
|
||||||
where: {
|
where: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The user's memberships were cascade-deleted with the user row — queue a
|
||||||
|
// seat sync for each organisation they belonged to so the Stripe quantity
|
||||||
|
// trues down to the new member count (no proration, no credit).
|
||||||
|
for (const organisationId of memberOrganisationIds) {
|
||||||
|
await jobs.triggerJob({
|
||||||
|
name: 'internal.sync-organisation-seats',
|
||||||
|
payload: { organisationId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return deletedUser;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { RecipientRole } from '@prisma/client';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { isAssistantLastSigner, normalizeRecipientSigningOrders, sortRecipientsForSigningOrder } from './recipients';
|
||||||
|
|
||||||
|
describe('recipient signing order helpers', () => {
|
||||||
|
it('sorts CC recipients after ordered active recipients', () => {
|
||||||
|
const recipients = [
|
||||||
|
{ id: 1, role: RecipientRole.CC, signingOrder: 1 },
|
||||||
|
{ id: 2, role: RecipientRole.SIGNER, signingOrder: 2 },
|
||||||
|
{ id: 3, role: RecipientRole.APPROVER, signingOrder: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(sortRecipientsForSigningOrder(recipients).map((recipient) => recipient.id)).toEqual([3, 2, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps original order when recipients have the same signing order', () => {
|
||||||
|
const recipients = [
|
||||||
|
{ id: 2, role: RecipientRole.SIGNER, signingOrder: 1 },
|
||||||
|
{ id: 1, role: RecipientRole.APPROVER, signingOrder: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(sortRecipientsForSigningOrder(recipients).map((recipient) => recipient.id)).toEqual([2, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts and normalizes active recipient signing order and removes it from CC recipients', () => {
|
||||||
|
const recipients = [
|
||||||
|
{ id: 1, role: RecipientRole.CC, signingOrder: 1 },
|
||||||
|
{ id: 2, role: RecipientRole.SIGNER, signingOrder: 4 },
|
||||||
|
{ id: 3, role: RecipientRole.APPROVER, signingOrder: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(normalizeRecipientSigningOrders(sortRecipientsForSigningOrder(recipients))).toEqual([
|
||||||
|
{ id: 3, role: RecipientRole.APPROVER, signingOrder: 1 },
|
||||||
|
{ id: 2, role: RecipientRole.SIGNER, signingOrder: 2 },
|
||||||
|
{ id: 1, role: RecipientRole.CC, signingOrder: undefined },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves caller order while normalizing signing order', () => {
|
||||||
|
const recipients = [
|
||||||
|
{ id: 2, role: RecipientRole.ASSISTANT, signingOrder: 2 },
|
||||||
|
{ id: 1, role: RecipientRole.SIGNER, signingOrder: 1 },
|
||||||
|
{ id: 3, role: RecipientRole.CC, signingOrder: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(normalizeRecipientSigningOrders(recipients)).toEqual([
|
||||||
|
{ id: 2, role: RecipientRole.ASSISTANT, signingOrder: 1 },
|
||||||
|
{ id: 1, role: RecipientRole.SIGNER, signingOrder: 2 },
|
||||||
|
{ id: 3, role: RecipientRole.CC, signingOrder: undefined },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks whether the last non-CC recipient is an assistant', () => {
|
||||||
|
expect(
|
||||||
|
isAssistantLastSigner([
|
||||||
|
{ role: RecipientRole.SIGNER },
|
||||||
|
{ role: RecipientRole.ASSISTANT },
|
||||||
|
{ role: RecipientRole.CC },
|
||||||
|
]),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isAssistantLastSigner([
|
||||||
|
{ role: RecipientRole.ASSISTANT },
|
||||||
|
{ role: RecipientRole.SIGNER },
|
||||||
|
{ role: RecipientRole.CC },
|
||||||
|
]),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||||
import type { Envelope } from '@prisma/client';
|
import type { Envelope, Field, Recipient } from '@prisma/client';
|
||||||
import { type Field, RecipientRole, SigningStatus } from '@prisma/client';
|
import { RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
|
||||||
import { AppError, AppErrorCode } from '../errors/app-error';
|
import { AppError, AppErrorCode } from '../errors/app-error';
|
||||||
@@ -15,6 +15,58 @@ import { zEmail } from './zod';
|
|||||||
*/
|
*/
|
||||||
export const RECIPIENT_ROLES_THAT_REQUIRE_FIELDS = [RecipientRole.SIGNER] as const;
|
export const RECIPIENT_ROLES_THAT_REQUIRE_FIELDS = [RecipientRole.SIGNER] as const;
|
||||||
|
|
||||||
|
// signingOrder isn't required when submitting the recipient form (Zod: z.number().optional())
|
||||||
|
type RecipientWithSigningOrder = Pick<Recipient, 'role'> & Partial<Pick<Recipient, 'signingOrder'>>;
|
||||||
|
|
||||||
|
export const isCcRecipient = (recipient: Pick<Recipient, 'role'>) => {
|
||||||
|
return recipient.role === RecipientRole.CC;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isAssistantLastSigner = (recipients: Pick<Recipient, 'role'>[]) => {
|
||||||
|
const nonCcRecipients = recipients.filter((recipient) => !isCcRecipient(recipient));
|
||||||
|
const lastNonCcRecipient = nonCcRecipients[nonCcRecipients.length - 1];
|
||||||
|
|
||||||
|
return lastNonCcRecipient?.role === RecipientRole.ASSISTANT;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sortRecipientsForSigningOrder = <T extends RecipientWithSigningOrder>(recipients: T[]): T[] => {
|
||||||
|
return [...recipients].sort((r1, r2) => {
|
||||||
|
const r1IsCcRecipient = isCcRecipient(r1);
|
||||||
|
const r2IsCcRecipient = isCcRecipient(r2);
|
||||||
|
|
||||||
|
// CC recipients always sort after non-CC recipients.
|
||||||
|
if (r1IsCcRecipient !== r2IsCcRecipient) {
|
||||||
|
return r1IsCcRecipient ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order by signing order; missing orders sort last.
|
||||||
|
const r1SigningOrder = r1.signingOrder ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
const r2SigningOrder = r2.signingOrder ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
|
||||||
|
return r1SigningOrder - r2SigningOrder;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeRecipientSigningOrders = <T extends RecipientWithSigningOrder>(
|
||||||
|
recipients: T[],
|
||||||
|
canUpdateRecipient: (recipient: T) => boolean = () => true,
|
||||||
|
): Array<T & { signingOrder?: number }> => {
|
||||||
|
const nonCcRecipients = recipients.filter((recipient) => !isCcRecipient(recipient));
|
||||||
|
const ccRecipients = recipients.filter((recipient) => isCcRecipient(recipient));
|
||||||
|
|
||||||
|
const normalizedNonCcRecipients = nonCcRecipients.map((recipient, index) => ({
|
||||||
|
...recipient,
|
||||||
|
signingOrder: canUpdateRecipient(recipient) ? index + 1 : (recipient.signingOrder ?? index + 1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const normalizedCcRecipients = ccRecipients.map((recipient) => ({
|
||||||
|
...recipient,
|
||||||
|
signingOrder: undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [...normalizedNonCcRecipients, ...normalizedCcRecipients];
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns recipients who are missing required fields for their role.
|
* Returns recipients who are missing required fields for their role.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -361,9 +361,9 @@ export const seedAlignmentTestDocument = async ({
|
|||||||
const { id, recipients, envelopeItems } = createdEnvelope;
|
const { id, recipients, envelopeItems } = createdEnvelope;
|
||||||
|
|
||||||
if (isDirectTemplate) {
|
if (isDirectTemplate) {
|
||||||
const directTemplateRecpient = recipients.find((recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL);
|
const directTemplateRecipient = recipients.find((recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL);
|
||||||
|
|
||||||
if (!directTemplateRecpient) {
|
if (!directTemplateRecipient) {
|
||||||
throw new Error('Need to create a direct template recipient');
|
throw new Error('Need to create a direct template recipient');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,7 +372,7 @@ export const seedAlignmentTestDocument = async ({
|
|||||||
envelopeId: id,
|
envelopeId: id,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
token: directTemplateToken ?? Math.random().toString(),
|
token: directTemplateToken ?? Math.random().toString(),
|
||||||
directTemplateRecipientId: directTemplateRecpient.id,
|
directTemplateRecipientId: directTemplateRecipient.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
DIRECT_TEMPLATE_RECIPIENT_NAME,
|
DIRECT_TEMPLATE_RECIPIENT_NAME,
|
||||||
} from '@documenso/lib/constants/direct-templates';
|
} from '@documenso/lib/constants/direct-templates';
|
||||||
import { incrementTemplateId } from '@documenso/lib/server-only/envelope/increment-id';
|
import { incrementTemplateId } from '@documenso/lib/server-only/envelope/increment-id';
|
||||||
|
import { FIELD_SIGNATURE_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
||||||
import { SignatureLevel } from '@documenso/lib/types/signature-level';
|
import { SignatureLevel } from '@documenso/lib/types/signature-level';
|
||||||
import { prefixedId } from '@documenso/lib/universal/id';
|
import { prefixedId } from '@documenso/lib/universal/id';
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
DocumentDataType,
|
DocumentDataType,
|
||||||
DocumentSource,
|
DocumentSource,
|
||||||
EnvelopeType,
|
EnvelopeType,
|
||||||
|
FieldType,
|
||||||
ReadStatus,
|
ReadStatus,
|
||||||
RecipientRole,
|
RecipientRole,
|
||||||
SendStatus,
|
SendStatus,
|
||||||
@@ -29,6 +31,11 @@ type SeedTemplateOptions = {
|
|||||||
teamId: number;
|
teamId: number;
|
||||||
internalVersion?: 1 | 2;
|
internalVersion?: 1 | 2;
|
||||||
createTemplateOptions?: Partial<Prisma.EnvelopeUncheckedCreateInput>;
|
createTemplateOptions?: Partial<Prisma.EnvelopeUncheckedCreateInput>;
|
||||||
|
/**
|
||||||
|
* Only used by seedDirectTemplate. Creates a signature field for the direct
|
||||||
|
* recipient so the seeded direct template is valid. Defaults to true.
|
||||||
|
*/
|
||||||
|
createDirectRecipientSignatureField?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CreateTemplateOptions = {
|
type CreateTemplateOptions = {
|
||||||
@@ -198,11 +205,11 @@ export const seedDirectTemplate = async (options: SeedTemplateOptions) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const directTemplateRecpient = template.recipients.find(
|
const directTemplateRecipient = template.recipients.find(
|
||||||
(recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL,
|
(recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!directTemplateRecpient) {
|
if (!directTemplateRecipient) {
|
||||||
throw new Error('Need to create a direct template recipient');
|
throw new Error('Need to create a direct template recipient');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,10 +218,35 @@ export const seedDirectTemplate = async (options: SeedTemplateOptions) => {
|
|||||||
envelopeId: template.id,
|
envelopeId: template.id,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
token: Math.random().toString(),
|
token: Math.random().toString(),
|
||||||
directTemplateRecipientId: directTemplateRecpient.id,
|
directTemplateRecipientId: directTemplateRecipient.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { createDirectRecipientSignatureField = true } = options;
|
||||||
|
|
||||||
|
if (createDirectRecipientSignatureField) {
|
||||||
|
const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({
|
||||||
|
where: { envelopeId: template.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.field.create({
|
||||||
|
data: {
|
||||||
|
envelopeId: template.id,
|
||||||
|
envelopeItemId: envelopeItem.id,
|
||||||
|
recipientId: directTemplateRecipient.id,
|
||||||
|
type: FieldType.SIGNATURE,
|
||||||
|
page: 1,
|
||||||
|
positionX: 5,
|
||||||
|
positionY: 10,
|
||||||
|
width: 20,
|
||||||
|
height: 5,
|
||||||
|
customText: '',
|
||||||
|
inserted: false,
|
||||||
|
fieldMeta: FIELD_SIGNATURE_META_DEFAULT_VALUES,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return await prisma.envelope.findFirstOrThrow({
|
return await prisma.envelope.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
id: template.id,
|
id: template.id,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user