mirror of
https://github.com/documenso/documenso.git
synced 2026-08-15 02:53:32 +10:00
Compare commits
11
Commits
4c88a9b8f7
...
bafde02170
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bafde02170 | ||
|
|
f0ab7c112e | ||
|
|
8bfcec8ee6 | ||
|
|
df3a488603 | ||
|
|
9c27ce6d18 | ||
|
|
b3c609a549 | ||
|
|
6b2fdf4f3b | ||
|
|
29020bcbed | ||
|
|
cf7787c004 | ||
|
|
6ec67d1c4d | ||
|
|
a457e1ef7d |
@@ -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.
|
||||
@@ -33,13 +33,14 @@ All webhook events share a common structure:
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------------- | --------- | ------------------------------------------------------ |
|
||||
| `id` | number | Document or template ID |
|
||||
| `id` | number | Legacy numeric v1 document or template ID |
|
||||
| `envelopeId` | string | Canonical v2 identifier (`envelope_` + 16 characters) |
|
||||
| `externalId` | string? | External identifier for integration |
|
||||
| `userId` | number | Owner's user ID |
|
||||
| `authOptions` | object? | Document-level authentication options |
|
||||
| `formValues` | object? | PDF form values associated with the document |
|
||||
| `title` | string | Document or template title |
|
||||
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED` |
|
||||
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, `CANCELLED` |
|
||||
| `visibility` | string | Document visibility setting |
|
||||
| `createdAt` | datetime | Document creation timestamp |
|
||||
| `updatedAt` | datetime | Last modification timestamp |
|
||||
@@ -47,8 +48,8 @@ All webhook events share a common structure:
|
||||
| `deletedAt` | datetime? | Deletion timestamp |
|
||||
| `teamId` | number? | Team ID if document belongs to a team |
|
||||
| `templateId` | number? | Template ID if created from a template |
|
||||
| `source` | string | Source: `DOCUMENT` or `TEMPLATE` |
|
||||
| `documentMeta` | object | Document metadata (subject, message, signing options) |
|
||||
| `source` | string | Source: `DOCUMENT`, `TEMPLATE`, or `TEMPLATE_DIRECT_LINK` |
|
||||
| `documentMeta` | object? | Nullable document metadata (subject, message, signing options) |
|
||||
| `recipients` | array | List of recipient objects |
|
||||
| `Recipient` | array | List of recipient objects (legacy, same as recipients) |
|
||||
|
||||
@@ -60,7 +61,6 @@ All webhook events share a common structure:
|
||||
| `subject` | string? | Email subject line |
|
||||
| `message` | string? | Email message body |
|
||||
| `timezone` | string | Timezone for date display |
|
||||
| `password` | string? | Document access password (if set) |
|
||||
| `dateFormat` | string | Date format string |
|
||||
| `redirectUrl` | string? | URL to redirect after signing |
|
||||
| `signingOrder` | string | `PARALLEL` or `SEQUENTIAL` |
|
||||
@@ -77,8 +77,9 @@ All webhook events share a common structure:
|
||||
| Field | Type | Description |
|
||||
| ---------------------- | --------- | ------------------------------------------ |
|
||||
| `id` | number | Recipient ID |
|
||||
| `documentId` | number? | Parent document ID |
|
||||
| `templateId` | number? | Template ID if created from a template |
|
||||
| `envelopeId` | string | Canonical parent envelope ID |
|
||||
| `documentId` | number? | Legacy parent document ID; null for templates |
|
||||
| `templateId` | number? | Legacy parent template ID; null for documents |
|
||||
| `email` | string | Recipient email address |
|
||||
| `name` | string | Recipient name |
|
||||
| `token` | string | Unique signing token |
|
||||
@@ -94,6 +95,8 @@ All webhook events share a common structure:
|
||||
| `sendStatus` | string | `NOT_SENT` or `SENT` |
|
||||
| `rejectionReason` | string? | Reason if recipient rejected |
|
||||
|
||||
Use `recipient.envelopeId` as the reliable parent link. The legacy `documentId` and `templateId` fields depend on the parent envelope type, so one of them is always null.
|
||||
|
||||
---
|
||||
|
||||
## Document Lifecycle Events
|
||||
@@ -111,6 +114,7 @@ Triggered when a new document is created.
|
||||
"event": "DOCUMENT_CREATED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"externalId": null,
|
||||
"userId": 1,
|
||||
"authOptions": null,
|
||||
@@ -129,9 +133,8 @@ Triggered when a new document is created.
|
||||
"id": "doc_meta_123",
|
||||
"subject": "Please sign this document",
|
||||
"message": "Hello, please review and sign this document.",
|
||||
"timezone": "UTC",
|
||||
"password": null,
|
||||
"dateFormat": "MM/DD/YYYY",
|
||||
"timezone": "Etc/UTC",
|
||||
"dateFormat": "yyyy-MM-dd hh:mm a",
|
||||
"redirectUrl": null,
|
||||
"signingOrder": "PARALLEL",
|
||||
"allowDictateNextSigner": false,
|
||||
@@ -145,6 +148,7 @@ Triggered when a new document is created.
|
||||
"recipients": [
|
||||
{
|
||||
"id": 52,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"documentId": 10,
|
||||
"templateId": null,
|
||||
"email": "signer@example.com",
|
||||
@@ -166,6 +170,7 @@ Triggered when a new document is created.
|
||||
"Recipient": [
|
||||
{
|
||||
"id": 52,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"documentId": 10,
|
||||
"templateId": null,
|
||||
"email": "signer@example.com",
|
||||
@@ -203,6 +208,7 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
|
||||
"event": "DOCUMENT_SENT",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"externalId": null,
|
||||
"userId": 1,
|
||||
"authOptions": null,
|
||||
@@ -221,9 +227,8 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
|
||||
"id": "doc_meta_123",
|
||||
"subject": "Please sign this document",
|
||||
"message": "Hello, please review and sign this document.",
|
||||
"timezone": "UTC",
|
||||
"password": null,
|
||||
"dateFormat": "MM/DD/YYYY",
|
||||
"timezone": "Etc/UTC",
|
||||
"dateFormat": "yyyy-MM-dd hh:mm a",
|
||||
"redirectUrl": null,
|
||||
"signingOrder": "PARALLEL",
|
||||
"allowDictateNextSigner": false,
|
||||
@@ -237,6 +242,7 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
|
||||
"recipients": [
|
||||
{
|
||||
"id": 52,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"documentId": 10,
|
||||
"templateId": null,
|
||||
"email": "signer@example.com",
|
||||
@@ -258,6 +264,7 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
|
||||
"Recipient": [
|
||||
{
|
||||
"id": 52,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"documentId": 10,
|
||||
"templateId": null,
|
||||
"email": "signer@example.com",
|
||||
@@ -295,12 +302,14 @@ The recipient's `readStatus` changes to `OPENED`.
|
||||
"event": "DOCUMENT_OPENED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"status": "PENDING",
|
||||
"title": "contract.pdf",
|
||||
"source": "DOCUMENT",
|
||||
"recipients": [
|
||||
{
|
||||
"id": 52,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"email": "signer@example.com",
|
||||
"name": "John Doe",
|
||||
"role": "SIGNER",
|
||||
@@ -328,6 +337,7 @@ The recipient's `signingStatus` changes to `SIGNED` and `signedAt` is populated.
|
||||
"event": "DOCUMENT_SIGNED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"status": "COMPLETED",
|
||||
"title": "contract.pdf",
|
||||
"source": "DOCUMENT",
|
||||
@@ -335,6 +345,7 @@ The recipient's `signingStatus` changes to `SIGNED` and `signedAt` is populated.
|
||||
"recipients": [
|
||||
{
|
||||
"id": 51,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"email": "signer@example.com",
|
||||
"name": "John Doe",
|
||||
"role": "SIGNER",
|
||||
@@ -361,12 +372,14 @@ Triggered when an individual recipient completes their required action (signing,
|
||||
"event": "DOCUMENT_RECIPIENT_COMPLETED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"status": "PENDING",
|
||||
"title": "contract.pdf",
|
||||
"source": "DOCUMENT",
|
||||
"recipients": [
|
||||
{
|
||||
"id": 52,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"email": "signer@example.com",
|
||||
"name": "John Doe",
|
||||
"role": "SIGNER",
|
||||
@@ -395,6 +408,7 @@ The document status changes to `COMPLETED` and `completedAt` is set.
|
||||
"event": "DOCUMENT_COMPLETED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"externalId": null,
|
||||
"userId": 1,
|
||||
"authOptions": null,
|
||||
@@ -413,9 +427,8 @@ The document status changes to `COMPLETED` and `completedAt` is set.
|
||||
"id": "doc_meta_123",
|
||||
"subject": "Please sign this document",
|
||||
"message": "Hello, please review and sign this document.",
|
||||
"timezone": "UTC",
|
||||
"password": null,
|
||||
"dateFormat": "MM/DD/YYYY",
|
||||
"timezone": "Etc/UTC",
|
||||
"dateFormat": "yyyy-MM-dd hh:mm a",
|
||||
"redirectUrl": null,
|
||||
"signingOrder": "PARALLEL",
|
||||
"allowDictateNextSigner": false,
|
||||
@@ -429,6 +442,7 @@ The document status changes to `COMPLETED` and `completedAt` is set.
|
||||
"recipients": [
|
||||
{
|
||||
"id": 50,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"documentId": 10,
|
||||
"templateId": null,
|
||||
"email": "reviewer@example.com",
|
||||
@@ -451,6 +465,7 @@ The document status changes to `COMPLETED` and `completedAt` is set.
|
||||
},
|
||||
{
|
||||
"id": 51,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"documentId": 10,
|
||||
"templateId": null,
|
||||
"email": "signer@example.com",
|
||||
@@ -475,6 +490,7 @@ The document status changes to `COMPLETED` and `completedAt` is set.
|
||||
"Recipient": [
|
||||
{
|
||||
"id": 50,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"documentId": 10,
|
||||
"templateId": null,
|
||||
"email": "reviewer@example.com",
|
||||
@@ -497,6 +513,7 @@ The document status changes to `COMPLETED` and `completedAt` is set.
|
||||
},
|
||||
{
|
||||
"id": 51,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"documentId": 10,
|
||||
"templateId": null,
|
||||
"email": "signer@example.com",
|
||||
@@ -537,12 +554,14 @@ The recipient's `signingStatus` changes to `REJECTED` and `rejectionReason` cont
|
||||
"event": "DOCUMENT_REJECTED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"status": "PENDING",
|
||||
"title": "contract.pdf",
|
||||
"source": "DOCUMENT",
|
||||
"recipients": [
|
||||
{
|
||||
"id": 52,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"email": "signer@example.com",
|
||||
"name": "John Doe",
|
||||
"role": "SIGNER",
|
||||
@@ -561,7 +580,7 @@ The recipient's `signingStatus` changes to `REJECTED` and `rejectionReason` cont
|
||||
|
||||
### `document.cancelled`
|
||||
|
||||
Triggered when the document owner or a team member deletes a document. Draft and pending documents are hard-deleted, while completed documents are soft-deleted.
|
||||
Triggered when a pending document is explicitly cancelled with `POST /envelope/cancel`, or when a document owner or team member deletes a document. Deleting a draft or pending document hard-deletes it, while deleting a completed document soft-deletes it.
|
||||
|
||||
This event is **not** triggered when a recipient hides a document from their inbox.
|
||||
|
||||
@@ -572,6 +591,7 @@ This event is **not** triggered when a recipient hides a document from their inb
|
||||
"event": "DOCUMENT_CANCELLED",
|
||||
"payload": {
|
||||
"id": 7,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"externalId": null,
|
||||
"userId": 3,
|
||||
"authOptions": null,
|
||||
@@ -591,7 +611,6 @@ This event is **not** triggered when a recipient hides a document from their inb
|
||||
"subject": "",
|
||||
"message": "",
|
||||
"timezone": "Etc/UTC",
|
||||
"password": null,
|
||||
"dateFormat": "yyyy-MM-dd hh:mm a",
|
||||
"redirectUrl": "",
|
||||
"signingOrder": "PARALLEL",
|
||||
@@ -606,6 +625,7 @@ This event is **not** triggered when a recipient hides a document from their inb
|
||||
"recipients": [
|
||||
{
|
||||
"id": 7,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"documentId": 7,
|
||||
"templateId": null,
|
||||
"email": "signer@example.com",
|
||||
@@ -627,6 +647,7 @@ This event is **not** triggered when a recipient hides a document from their inb
|
||||
"Recipient": [
|
||||
{
|
||||
"id": 7,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"documentId": 7,
|
||||
"templateId": null,
|
||||
"email": "signer@example.com",
|
||||
@@ -651,6 +672,45 @@ This event is **not** triggered when a recipient hides a document from their inb
|
||||
}
|
||||
```
|
||||
|
||||
### `recipient.expired`
|
||||
|
||||
Triggered when a recipient's signing deadline passes on a pending document before they sign or reject it.
|
||||
|
||||
**Event name:** `RECIPIENT_EXPIRED`
|
||||
|
||||
The recipient's `expiresAt` contains the signing deadline, and `expirationNotifiedAt` is set when the expiration is processed.
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "RECIPIENT_EXPIRED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"status": "PENDING",
|
||||
"title": "contract.pdf",
|
||||
"source": "DOCUMENT",
|
||||
"recipients": [
|
||||
{
|
||||
"id": 52,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"documentId": 10,
|
||||
"templateId": null,
|
||||
"email": "signer@example.com",
|
||||
"name": "John Doe",
|
||||
"role": "SIGNER",
|
||||
"expiresAt": "2024-04-22T11:51:00.000Z",
|
||||
"expirationNotifiedAt": "2024-04-22T11:52:00.000Z",
|
||||
"readStatus": "OPENED",
|
||||
"signingStatus": "NOT_SIGNED",
|
||||
"sendStatus": "SENT"
|
||||
}
|
||||
]
|
||||
},
|
||||
"createdAt": "2024-04-22T11:52:00.000Z",
|
||||
"webhookEndpoint": "https://your-endpoint.com/webhook"
|
||||
}
|
||||
```
|
||||
|
||||
### `document.reminder.sent`
|
||||
|
||||
Triggered when a reminder email is sent to a recipient who has not yet completed their action.
|
||||
@@ -662,12 +722,14 @@ Triggered when a reminder email is sent to a recipient who has not yet completed
|
||||
"event": "DOCUMENT_REMINDER_SENT",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"status": "PENDING",
|
||||
"title": "contract.pdf",
|
||||
"source": "DOCUMENT",
|
||||
"recipients": [
|
||||
{
|
||||
"id": 52,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"email": "signer@example.com",
|
||||
"name": "John Doe",
|
||||
"role": "SIGNER",
|
||||
@@ -686,7 +748,7 @@ Triggered when a reminder email is sent to a recipient who has not yet completed
|
||||
|
||||
## Template Events
|
||||
|
||||
Template events track changes to reusable document templates. Template payloads use the same structure as document payloads, with `source` set to `TEMPLATE` and `templateId` populated.
|
||||
Template events track changes to reusable document templates. Template payloads use the same structure as document payloads. For `TEMPLATE_CREATED`, `TEMPLATE_UPDATED`, and `TEMPLATE_DELETED` the template's own legacy numeric ID is in `id` and `templateId` is `null`. Only `TEMPLATE_USED` — whose payload describes the new document envelope created from the template — carries the originating template's legacy ID in `templateId`, with `source` set to `TEMPLATE`.
|
||||
|
||||
### `template.created`
|
||||
|
||||
@@ -699,9 +761,10 @@ Triggered when a new template is created.
|
||||
"event": "TEMPLATE_CREATED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"title": "My Template",
|
||||
"status": "DRAFT",
|
||||
"templateId": 10,
|
||||
"templateId": null,
|
||||
"source": "TEMPLATE",
|
||||
"recipients": []
|
||||
},
|
||||
@@ -721,9 +784,10 @@ Triggered when a template's settings, recipients, or fields are modified.
|
||||
"event": "TEMPLATE_UPDATED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"title": "My Updated Template",
|
||||
"status": "DRAFT",
|
||||
"templateId": 10,
|
||||
"templateId": null,
|
||||
"source": "TEMPLATE",
|
||||
"recipients": []
|
||||
},
|
||||
@@ -743,9 +807,10 @@ Triggered when a template is deleted.
|
||||
"event": "TEMPLATE_DELETED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"title": "Deleted Template",
|
||||
"status": "DRAFT",
|
||||
"templateId": 10,
|
||||
"templateId": null,
|
||||
"source": "TEMPLATE",
|
||||
"recipients": []
|
||||
},
|
||||
@@ -765,6 +830,7 @@ Triggered when a document is created from a template. This event fires alongside
|
||||
"event": "TEMPLATE_USED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"title": "Document from Template",
|
||||
"status": "DRAFT",
|
||||
"templateId": 10,
|
||||
@@ -791,7 +857,8 @@ Triggered when a document is created from a template. This event fires alongside
|
||||
| `DOCUMENT_RECIPIENT_COMPLETED` | Recipient completes their action | Recipient `signingStatus: "SIGNED"`, `signedAt` set |
|
||||
| `DOCUMENT_COMPLETED` | All recipients complete actions | `status: "COMPLETED"`, `completedAt` set |
|
||||
| `DOCUMENT_REJECTED` | Recipient rejects document | Recipient `signingStatus: "REJECTED"`, `rejectionReason` set |
|
||||
| `DOCUMENT_CANCELLED` | Owner or team member deletes document | Document cancelled or deleted |
|
||||
| `DOCUMENT_CANCELLED` | Pending document explicitly cancelled, or document deleted | `status: "CANCELLED"` after explicit cancellation; deletion may remove or soft-delete the document |
|
||||
| `RECIPIENT_EXPIRED` | Recipient signing deadline passes | Recipient `expiresAt` passed, `expirationNotifiedAt` set |
|
||||
| `DOCUMENT_REMINDER_SENT` | Reminder email sent to recipient | No status changes |
|
||||
|
||||
### Template Events
|
||||
@@ -821,7 +888,7 @@ When processing webhook events:
|
||||
**Process idempotently** — Webhooks may be retried, so handle duplicate events
|
||||
</Step>
|
||||
<Step>
|
||||
**Respond quickly** — Return a 200 status code within 30 seconds
|
||||
**Respond quickly** — Return a `2xx` status code within 10 seconds
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ description: Receive real-time notifications for document and template events.
|
||||
2. When an event occurs, Documenso sends an HTTP POST to your URL
|
||||
3. Your application processes the event and responds with 200 OK
|
||||
|
||||
Documenso supports webhook events for the full document lifecycle (created, sent, opened, signed, completed, rejected, cancelled) as well as template events (created, updated, deleted, used).
|
||||
Documenso supports webhook events for the full document lifecycle (created, sent, opened, signed, completed, rejected, cancelled), recipient-level events (recipient completed, reminder sent, recipient expired), and template events (created, updated, deleted, used).
|
||||
|
||||
---
|
||||
|
||||
@@ -42,12 +42,14 @@ Documenso supports webhook events for the full document lifecycle (created, sent
|
||||
"event": "DOCUMENT_COMPLETED",
|
||||
"payload": {
|
||||
"id": 123,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"title": "Contract",
|
||||
"status": "COMPLETED",
|
||||
"completedAt": "2024-01-15T10:30:00.000Z",
|
||||
"recipients": [
|
||||
{
|
||||
"id": 1,
|
||||
"envelopeId": "envelope_abcdefhiklmnorst",
|
||||
"email": "signer@example.com",
|
||||
"signingStatus": "SIGNED"
|
||||
}
|
||||
@@ -58,6 +60,8 @@ Documenso supports webhook events for the full document lifecycle (created, sent
|
||||
}
|
||||
```
|
||||
|
||||
`payload.id` is the legacy numeric v1 ID. Use `payload.envelopeId` as the canonical v2 identifier. Each recipient repeats `envelopeId` as the reliable parent link because the legacy `documentId` and `templateId` fields depend on the parent envelope type, leaving one of them null.
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
@@ -148,7 +148,7 @@ func main() {
|
||||
</Tabs>
|
||||
|
||||
<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>
|
||||
|
||||
## Configuring Webhooks in Documenso via the Dashboard
|
||||
@@ -184,7 +184,7 @@ Fill in the following fields:
|
||||
|
||||
| Field | Description |
|
||||
| ----- | ----------- |
|
||||
| **Webhook URL** | The HTTPS endpoint that will receive webhook events |
|
||||
| **Webhook URL** | The HTTP or HTTPS endpoint that will receive webhook events |
|
||||
| **Events** | Select which events should trigger this webhook |
|
||||
| **Secret** (optional) | A secret key used to sign the payload for verification |
|
||||
</Step>
|
||||
@@ -202,12 +202,21 @@ Your webhook endpoint must meet these requirements:
|
||||
|
||||
| Requirement | Details |
|
||||
| ----------- | ------- |
|
||||
| **Protocol** | HTTPS required (HTTP not allowed in production) |
|
||||
| **Response** | Must return `2xx` status code within 30 seconds |
|
||||
| **Protocol** | HTTP and HTTPS are accepted; use HTTPS in production |
|
||||
| **Response** | Must return a `2xx` status code within 10 seconds |
|
||||
| **Method** | Must accept HTTP POST requests |
|
||||
| **Content-Type** | Must accept `application/json` payloads |
|
||||
| **Availability** | Must be publicly accessible from the internet |
|
||||
|
||||
<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">
|
||||
For local development, use a tunneling service like [ngrok](https://ngrok.com) or [localtunnel](https://localtunnel.me) to expose your local server.
|
||||
</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_COMPLETED` | All recipients have completed their actions |
|
||||
| `DOCUMENT_REJECTED` | A recipient rejects the document |
|
||||
| `DOCUMENT_CANCELLED` | The document owner deletes the document |
|
||||
| `DOCUMENT_CANCELLED` | A pending document is explicitly cancelled or a document owner deletes it |
|
||||
| `RECIPIENT_EXPIRED` | A recipient's signing deadline passes before they sign or reject |
|
||||
| `DOCUMENT_REMINDER_SENT` | A reminder email is sent to a recipient |
|
||||
| `TEMPLATE_CREATED` | A new template is created |
|
||||
| `TEMPLATE_UPDATED` | A template is modified |
|
||||
@@ -294,6 +304,7 @@ Each webhook call shows the following details:
|
||||
- Timestamp
|
||||
- Response code
|
||||
- Request and response bodies
|
||||
- Response headers
|
||||
|
||||
Click any call to see full details including headers and response data.
|
||||
</Step>
|
||||
@@ -318,17 +329,17 @@ Documenso will attempt to deliver the same payload again
|
||||
|
||||
## Retry Policy
|
||||
|
||||
When a webhook delivery fails (non-2xx response or timeout), Documenso automatically retries with exponential backoff:
|
||||
A delivery fails when the endpoint returns a non-`2xx` response, the 10-second timeout expires, or the request fails. Redirects are not followed, so `3xx` responses also fail. Network and SSRF-blocked requests are recorded with response code `0`.
|
||||
|
||||
| Attempt | Delay |
|
||||
| ------- | ----- |
|
||||
| 1 | Immediate |
|
||||
| 2 | 1 minute |
|
||||
| 3 | 5 minutes |
|
||||
| 4 | 30 minutes |
|
||||
| 5 | 2 hours |
|
||||
For self-hosted deployments, retries are handled by the background-job provider selected with `NEXT_PRIVATE_JOBS_PROVIDER`:
|
||||
|
||||
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">
|
||||
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_CANCELLED',
|
||||
'DOCUMENT_REMINDER_SENT',
|
||||
'RECIPIENT_EXPIRED',
|
||||
'TEMPLATE_CREATED',
|
||||
'TEMPLATE_UPDATED',
|
||||
'TEMPLATE_DELETED',
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
import {
|
||||
createZipWriter,
|
||||
sanitizeZipPathSegment,
|
||||
type ZipFileEntry,
|
||||
} from '@documenso/lib/client-only/create-zip-writer';
|
||||
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||
import { fetchPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { RadioGroupSegmented, RadioGroupSegmentedItem } from '@documenso/ui/primitives/radio-group';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { plural } from '@lingui/core/macro';
|
||||
import { Plural, Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
/**
|
||||
* The maximum number of documents that can be downloaded in a single bulk
|
||||
* download. Each document requires fetching its full PDFs into the browser,
|
||||
* so this bounds both request volume and blob storage usage. Matches the
|
||||
* spirit of the server-side 100 cap on bulk move/delete/cancel.
|
||||
*/
|
||||
export const MAX_BULK_DOWNLOAD_ENVELOPES = 50;
|
||||
|
||||
type BulkDownloadVersion = 'signed' | 'original' | 'pending';
|
||||
|
||||
export type EnvelopeBulkDownloadItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: DocumentStatus;
|
||||
|
||||
/**
|
||||
* Whether the envelope is a legacy (v1) envelope. Legacy envelopes use a
|
||||
* different field-rendering pipeline that the partial PDF helper does not
|
||||
* implement, so the Partial option is hidden for them.
|
||||
*/
|
||||
isLegacy: boolean;
|
||||
};
|
||||
|
||||
const getDefaultVersion = (envelope: EnvelopeBulkDownloadItem): BulkDownloadVersion =>
|
||||
envelope.status === DocumentStatus.COMPLETED ? 'signed' : 'original';
|
||||
|
||||
export type EnvelopesBulkDownloadDialogProps = {
|
||||
envelopes: EnvelopeBulkDownloadItem[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: (successfulEnvelopeIds: string[]) => void;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
export const EnvelopesBulkDownloadDialog = ({
|
||||
envelopes,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
...props
|
||||
}: EnvelopesBulkDownloadDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [versionMap, setVersionMap] = useState<Record<string, BulkDownloadVersion>>({});
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
const abortRef = useRef(false);
|
||||
|
||||
const trpcUtils = trpc.useUtils();
|
||||
|
||||
const isOverDownloadLimit = envelopes.length > MAX_BULK_DOWNLOAD_ENVELOPES;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
setVersionMap(Object.fromEntries(envelopes.map((envelope) => [envelope.id, getDefaultVersion(envelope)])));
|
||||
setProgress(0);
|
||||
}, [open]);
|
||||
|
||||
const getDownloadVersion = (envelope: EnvelopeBulkDownloadItem): BulkDownloadVersion =>
|
||||
versionMap[envelope.id] ?? getDefaultVersion(envelope);
|
||||
|
||||
/**
|
||||
* The version options selectable for an envelope, mirroring the gating used
|
||||
* by the single envelope download dialog:
|
||||
* - COMPLETED: signed or original.
|
||||
* - PENDING (non-legacy): partial or original. Legacy envelopes use a
|
||||
* field-rendering pipeline the partial PDF helper does not implement.
|
||||
* - Anything else: original only, so no choice is shown.
|
||||
*/
|
||||
const getVersionOptions = (
|
||||
envelope: EnvelopeBulkDownloadItem,
|
||||
): { value: BulkDownloadVersion; label: string }[] | null => {
|
||||
if (envelope.status === DocumentStatus.COMPLETED) {
|
||||
return [
|
||||
{ value: 'signed', label: t({ message: 'Signed', context: 'Signed document (adjective)' }) },
|
||||
{ value: 'original', label: t({ message: 'Original', context: 'Original document (adjective)' }) },
|
||||
];
|
||||
}
|
||||
|
||||
if (envelope.status === DocumentStatus.PENDING && !envelope.isLegacy) {
|
||||
return [
|
||||
{ value: 'pending', label: t({ message: 'Partial', context: 'Partially signed document (adjective)' }) },
|
||||
{ value: 'original', label: t({ message: 'Original', context: 'Original document (adjective)' }) },
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: DocumentStatus) =>
|
||||
match(status)
|
||||
.with(DocumentStatus.COMPLETED, () => t`Completed`)
|
||||
.with(DocumentStatus.PENDING, () => t`Pending`)
|
||||
.with(DocumentStatus.DRAFT, () => t`Draft`)
|
||||
.with(DocumentStatus.REJECTED, () => t`Rejected`)
|
||||
.with(DocumentStatus.CANCELLED, () => t`Cancelled`)
|
||||
.exhaustive();
|
||||
|
||||
const onDownload = async () => {
|
||||
if (envelopes.length === 0 || isOverDownloadLimit || isDownloading) {
|
||||
return;
|
||||
}
|
||||
|
||||
abortRef.current = false;
|
||||
setIsDownloading(true);
|
||||
setProgress(0);
|
||||
|
||||
const zipWriter = createZipWriter();
|
||||
|
||||
const successfulEnvelopeIds: string[] = [];
|
||||
let failedDownloads = 0;
|
||||
|
||||
try {
|
||||
for (const envelope of envelopes) {
|
||||
if (abortRef.current) {
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const downloadVersion = getDownloadVersion(envelope);
|
||||
|
||||
const { data: envelopeItems } = await trpcUtils.envelope.item.getManyByToken.fetch({
|
||||
envelopeId: envelope.id,
|
||||
access: {
|
||||
type: 'user',
|
||||
},
|
||||
});
|
||||
|
||||
// Each envelope's items are grouped in their own folder. The id
|
||||
// prefix guarantees uniqueness, the truncated title keeps it
|
||||
// readable without risking overly long extraction paths.
|
||||
const folderName = sanitizeZipPathSegment(`${envelope.id}_${envelope.title}`.slice(0, 96));
|
||||
|
||||
// Buffer this envelope's files before writing so a failed envelope
|
||||
// is either fully in the zip or not at all. Files from previous
|
||||
// envelopes have already been written to the zip stream and freed.
|
||||
const envelopeFiles: ZipFileEntry[] = [];
|
||||
|
||||
for (const envelopeItem of envelopeItems) {
|
||||
const { filename, blob } = await fetchPDF({
|
||||
envelopeItem,
|
||||
token: undefined,
|
||||
fileName: envelopeItem.title,
|
||||
version: downloadVersion,
|
||||
});
|
||||
|
||||
envelopeFiles.push({
|
||||
filename: `${folderName}/${sanitizeZipPathSegment(filename)}`,
|
||||
data: blob,
|
||||
});
|
||||
}
|
||||
|
||||
for (const file of envelopeFiles) {
|
||||
await zipWriter.addFile(file);
|
||||
}
|
||||
|
||||
successfulEnvelopeIds.push(envelope.id);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
failedDownloads++;
|
||||
}
|
||||
|
||||
setProgress((p) => p + 1);
|
||||
}
|
||||
|
||||
// The user intentionally stopped the download, discard anything fetched
|
||||
// so far without toasting an error.
|
||||
if (abortRef.current) {
|
||||
zipWriter.abort();
|
||||
return;
|
||||
}
|
||||
|
||||
if (successfulEnvelopeIds.length === 0) {
|
||||
zipWriter.abort();
|
||||
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`An error occurred while downloading the documents.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
downloadFile({
|
||||
filename: `documenso-documents-${new Date().toISOString().slice(0, 10)}.zip`,
|
||||
data: zipWriter.finalize(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
zipWriter.abort();
|
||||
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`An error occurred while downloading the documents.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (failedDownloads > 0) {
|
||||
toast({
|
||||
title: t`Documents partially downloaded`,
|
||||
description: t`${plural(successfulEnvelopeIds.length, {
|
||||
one: '# document downloaded.',
|
||||
other: '# documents downloaded.',
|
||||
})} ${plural(failedDownloads, {
|
||||
one: '# document could not be downloaded.',
|
||||
other: '# documents could not be downloaded.',
|
||||
})}`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
onSuccess?.(successfulEnvelopeIds);
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t`Documents downloaded`,
|
||||
description: plural(successfulEnvelopeIds.length, {
|
||||
one: '# document has been downloaded.',
|
||||
other: '# documents have been downloaded.',
|
||||
}),
|
||||
});
|
||||
|
||||
onSuccess?.(successfulEnvelopeIds);
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => {
|
||||
if (!isDownloading) {
|
||||
onOpenChange(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Download Documents</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Plural
|
||||
value={envelopes.length}
|
||||
one="Select the version to download for the selected document."
|
||||
other="Select the version to download for each of the # selected documents."
|
||||
/>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isOverDownloadLimit && (
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
You can download up to {MAX_BULK_DOWNLOAD_ENVELOPES} documents at a time. Deselect some documents to
|
||||
continue.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<fieldset disabled={isDownloading} className="space-y-4">
|
||||
<div className="-mx-3 max-h-96 overflow-y-auto px-3">
|
||||
<div className="divide-y divide-border rounded-lg border border-border">
|
||||
{envelopes.map((envelope) => {
|
||||
const versionOptions = getVersionOptions(envelope);
|
||||
|
||||
return (
|
||||
<div key={envelope.id} className="flex items-center gap-3 px-3 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-foreground text-sm" title={envelope.title}>
|
||||
{envelope.title}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">{getStatusLabel(envelope.status)}</p>
|
||||
</div>
|
||||
|
||||
{versionOptions && (
|
||||
<RadioGroupSegmented
|
||||
className="shrink-0"
|
||||
value={getDownloadVersion(envelope)}
|
||||
onValueChange={(value) =>
|
||||
setVersionMap((prev) => ({
|
||||
...prev,
|
||||
[envelope.id]: value as BulkDownloadVersion,
|
||||
}))
|
||||
}
|
||||
aria-label={t`Download version for ${envelope.title}`}
|
||||
>
|
||||
{versionOptions.map((option) => (
|
||||
<RadioGroupSegmentedItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</RadioGroupSegmentedItem>
|
||||
))}
|
||||
</RadioGroupSegmented>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDownloading && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
Downloading {progress} / {envelopes.length}...
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (isDownloading) {
|
||||
abortRef.current = true;
|
||||
} else {
|
||||
onOpenChange(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isDownloading ? <Trans>Stop</Trans> : <Trans>Cancel</Trans>}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void onDownload()}
|
||||
loading={isDownloading}
|
||||
disabled={envelopes.length === 0 || isOverDownloadLimit}
|
||||
>
|
||||
<Trans>Download</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -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 { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@@ -9,6 +10,7 @@ import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
CheckCircle2Icon,
|
||||
CopyIcon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
KeyRoundIcon,
|
||||
@@ -29,6 +31,8 @@ type AdminLicenseCardProps = {
|
||||
|
||||
export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
|
||||
const { t, i18n } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const [, copy] = useCopyToClipboard();
|
||||
const [isLicenseKeyVisible, setIsLicenseKeyVisible] = useState(false);
|
||||
|
||||
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" />}
|
||||
</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>
|
||||
|
||||
|
||||
@@ -2,38 +2,24 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string }) => {
|
||||
import { documentsSearchParams } from '~/utils/documents-search-params';
|
||||
|
||||
export const DocumentSearch = () => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [query, setQuery] = useQueryState('query', documentsSearchParams.query);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(initialValue);
|
||||
const [searchTerm, setSearchTerm] = useState(query ?? '');
|
||||
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(term: string) => {
|
||||
const params = new URLSearchParams(searchParams?.toString() ?? '');
|
||||
if (term) {
|
||||
params.set('query', term);
|
||||
} else {
|
||||
params.delete('query');
|
||||
}
|
||||
|
||||
setSearchParams(params);
|
||||
},
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const currentQueryParam = searchParams.get('query') || '';
|
||||
|
||||
if (debouncedSearchTerm !== currentQueryParam) {
|
||||
handleSearch(debouncedSearchTerm);
|
||||
if (debouncedSearchTerm !== (query ?? '')) {
|
||||
void setQuery(debouncedSearchTerm || null);
|
||||
}
|
||||
}, [debouncedSearchTerm, searchParams]);
|
||||
}, [debouncedSearchTerm, query, setQuery]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cn } from '@documenso/ui/lib/utils';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
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 { HTMLAttributes } from 'react';
|
||||
|
||||
@@ -46,6 +46,12 @@ export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus>
|
||||
icon: XCircle,
|
||||
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: {
|
||||
label: msg`Inbox`,
|
||||
labelExtended: msg`Document inbox`,
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@documenso/ui/primitives/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { CheckIcon, ChevronDownIcon } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export type FilterPillOption = {
|
||||
value: string;
|
||||
label: ReactNode;
|
||||
trailing?: string;
|
||||
};
|
||||
|
||||
type FilterPillCommonProps = {
|
||||
icon: LucideIcon;
|
||||
label: ReactNode;
|
||||
options: FilterPillOption[];
|
||||
enableSearch?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
loading?: boolean;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
export type FilterPillSingleProps = FilterPillCommonProps & {
|
||||
multiple?: false;
|
||||
value: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
selectedLabel?: ReactNode;
|
||||
};
|
||||
|
||||
export type FilterPillMultipleProps = FilterPillCommonProps & {
|
||||
multiple: true;
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
};
|
||||
|
||||
export type FilterPillProps = FilterPillSingleProps | FilterPillMultipleProps;
|
||||
|
||||
/**
|
||||
* A faceted filter pill.
|
||||
*
|
||||
* Renders as a dashed "add a filter" pill at rest, and shows the current
|
||||
* selection inline once a value is picked. Selecting the active option
|
||||
* again (or the Clear row) removes it.
|
||||
*
|
||||
* Single select by default, closing on pick. When `multiple` is set the
|
||||
* popover stays open for toggling, and the trigger shows the first two
|
||||
* selections followed by a "+N more" chip.
|
||||
*/
|
||||
export const FilterPill = (props: FilterPillProps) => {
|
||||
const { icon: Icon, label, options, enableSearch, searchPlaceholder, loading, testId } = props;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selectedValues = props.multiple ? props.value : props.value === null ? [] : [props.value];
|
||||
|
||||
const selectedOptions = selectedValues
|
||||
.map((value) => options.find((option) => option.value === value))
|
||||
.filter((option): option is FilterPillOption => option !== undefined);
|
||||
|
||||
const hasSelection = selectedOptions.length > 0;
|
||||
const extraCount = selectedOptions.length - 2;
|
||||
|
||||
const onSelect = (nextValue: string) => {
|
||||
if (props.multiple) {
|
||||
const newValues = selectedValues.includes(nextValue)
|
||||
? selectedValues.filter((value) => value !== nextValue)
|
||||
: [...selectedValues, nextValue];
|
||||
|
||||
props.onChange(newValues);
|
||||
return;
|
||||
}
|
||||
|
||||
props.onChange(nextValue === props.value ? null : nextValue);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const onClear = () => {
|
||||
if (props.multiple) {
|
||||
props.onChange([]);
|
||||
} else {
|
||||
props.onChange(null);
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={loading}
|
||||
className={cn('border-dashed text-muted-foreground', {
|
||||
'border-solid text-foreground': hasSelection,
|
||||
})}
|
||||
data-testid={testId}
|
||||
>
|
||||
<Icon className="mr-2 h-4 w-4" />
|
||||
{label}
|
||||
|
||||
{hasSelection && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
|
||||
{props.multiple ? (
|
||||
<span className="flex items-center gap-x-1">
|
||||
{selectedOptions.slice(0, 2).map((option) => (
|
||||
<Badge key={option.value} variant="neutral" size="small">
|
||||
{option.label}
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
{extraCount > 0 && (
|
||||
<Badge variant="neutral" size="small">
|
||||
<Trans>+{extraCount} more</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-medium">{props.selectedLabel ?? selectedOptions[0].label}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ChevronDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="w-52 p-0" align="start">
|
||||
<Command>
|
||||
{enableSearch && <CommandInput placeholder={searchPlaceholder} />}
|
||||
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<Trans>No results found.</Trans>
|
||||
</CommandEmpty>
|
||||
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
<CommandItem key={option.value} onSelect={() => onSelect(option.value)}>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4 shrink-0',
|
||||
selectedValues.includes(option.value) ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
|
||||
{option.label}
|
||||
|
||||
{option.trailing !== undefined && (
|
||||
<span className="ml-auto pl-4 text-muted-foreground text-xs">{option.trailing}</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
{hasSelection && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup>
|
||||
<CommandItem className="justify-center text-center text-muted-foreground" onSelect={onClear}>
|
||||
<Trans>Clear</Trans>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
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';
|
||||
|
||||
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.`,
|
||||
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, () => ({
|
||||
title: msg`We're all empty`,
|
||||
message: msg`You have not yet created or received any documents. To create a document please upload one.`,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
import { useQueryStates } from 'nuqs';
|
||||
|
||||
import { FilterPill } from '~/components/general/filter-pill';
|
||||
import { DOCUMENTS_PERIOD_VALUES, documentsSearchParams } from '~/utils/documents-search-params';
|
||||
|
||||
const PERIOD_OPTIONS = [
|
||||
{ value: '7d', label: <Trans>Last 7 days</Trans> },
|
||||
{ value: '14d', label: <Trans>Last 14 days</Trans> },
|
||||
{ value: '30d', label: <Trans>Last 30 days</Trans> },
|
||||
];
|
||||
|
||||
export const DocumentsTablePeriodFilter = () => {
|
||||
const [{ period }, setSearchParams] = useQueryStates(
|
||||
{
|
||||
period: documentsSearchParams.period,
|
||||
page: documentsSearchParams.page,
|
||||
},
|
||||
{ history: 'push' },
|
||||
);
|
||||
|
||||
const onChange = (newPeriod: string | null) => {
|
||||
void setSearchParams({
|
||||
period: DOCUMENTS_PERIOD_VALUES.find((value) => value === newPeriod) ?? null,
|
||||
page: null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<FilterPill
|
||||
icon={CalendarIcon}
|
||||
label={<Trans>Period</Trans>}
|
||||
value={period}
|
||||
onChange={onChange}
|
||||
options={PERIOD_OPTIONS}
|
||||
testId="documents-table-period-filter"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,63 +1,61 @@
|
||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router';
|
||||
import { UserIcon } from 'lucide-react';
|
||||
import { useQueryStates } from 'nuqs';
|
||||
|
||||
import { FilterPill } from '~/components/general/filter-pill';
|
||||
import { documentsSearchParams } from '~/utils/documents-search-params';
|
||||
|
||||
type DocumentsTableSenderFilterProps = {
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const DocumentsTableSenderFilter = ({ teamId }: DocumentsTableSenderFilterProps) => {
|
||||
const { pathname } = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const senderIds = (searchParams?.get('senderIds') ?? '').split(',').filter((value) => value !== '');
|
||||
const [{ senderIds }, setSearchParams] = useQueryStates(
|
||||
{
|
||||
senderIds: documentsSearchParams.senderIds,
|
||||
page: documentsSearchParams.page,
|
||||
},
|
||||
{ history: 'push' },
|
||||
);
|
||||
|
||||
const selectedSenderIds = (senderIds ?? []).map((senderId) => senderId.toString());
|
||||
|
||||
const { data, isLoading } = trpc.team.member.getMany.useQuery({
|
||||
teamId,
|
||||
});
|
||||
|
||||
const comboBoxOptions = (data ?? []).map((member) => ({
|
||||
const options = (data ?? []).map((member) => ({
|
||||
label: member.name ?? member.email,
|
||||
value: member.userId.toString(),
|
||||
}));
|
||||
|
||||
const onChange = (newSenderIds: string[]) => {
|
||||
if (!pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set('senderIds', newSenderIds.join(','));
|
||||
|
||||
if (newSenderIds.length === 0) {
|
||||
params.delete('senderIds');
|
||||
}
|
||||
|
||||
void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true });
|
||||
void setSearchParams({
|
||||
senderIds: newSenderIds.length > 0 ? newSenderIds.map(Number) : null,
|
||||
page: null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<MultiSelectCombobox
|
||||
emptySelectionPlaceholder={
|
||||
<p className="font-normal text-muted-foreground">
|
||||
<Trans>
|
||||
<span className="text-muted-foreground/70">Sender:</span> All
|
||||
</Trans>
|
||||
</p>
|
||||
}
|
||||
enableClearAllButton={true}
|
||||
inputPlaceholder={msg`Search`}
|
||||
loading={!isMounted || isLoading}
|
||||
options={comboBoxOptions}
|
||||
selectedValues={senderIds}
|
||||
<FilterPill
|
||||
multiple
|
||||
icon={UserIcon}
|
||||
label={<Trans>Sender</Trans>}
|
||||
value={selectedSenderIds}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
enableSearch
|
||||
searchPlaceholder={_(msg`Search members...`)}
|
||||
loading={!isMounted || isLoading}
|
||||
testId="documents-table-sender-filter"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { STATS_COUNT_CAP } from '@documenso/lib/constants/document';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { OrganisationType } from '@prisma/client';
|
||||
import { ListFilterIcon } from 'lucide-react';
|
||||
import { useQueryStates } from 'nuqs';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { DocumentStatus, FRIENDLY_STATUS_MAP } from '~/components/general/document/document-status';
|
||||
import { FilterPill } from '~/components/general/filter-pill';
|
||||
import { documentsSearchParams } from '~/utils/documents-search-params';
|
||||
|
||||
type DocumentsTableStatusFilterProps = {
|
||||
stats: TFindDocumentsInternalResponse['stats'];
|
||||
};
|
||||
|
||||
export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilterProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const [{ status }, setSearchParams] = useQueryStates(
|
||||
{
|
||||
status: documentsSearchParams.status,
|
||||
page: documentsSearchParams.page,
|
||||
},
|
||||
{ history: 'push' },
|
||||
);
|
||||
|
||||
const selectableStatuses = useMemo(
|
||||
() =>
|
||||
SELECTABLE_STATUSES.filter((value) => {
|
||||
if (organisation.type === OrganisationType.PERSONAL) {
|
||||
return value !== ExtendedDocumentStatus.INBOX;
|
||||
}
|
||||
|
||||
return true;
|
||||
}),
|
||||
[organisation.type],
|
||||
);
|
||||
|
||||
const selectedStatus = useMemo(
|
||||
() => selectableStatuses.find((value) => value === status) ?? null,
|
||||
[selectableStatuses, status],
|
||||
);
|
||||
|
||||
const onChange = (newStatus: string | null) => {
|
||||
void setSearchParams({
|
||||
status: selectableStatuses.find((value) => value === newStatus) ?? null,
|
||||
page: null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterPill
|
||||
icon={ListFilterIcon}
|
||||
label={<Trans>Status</Trans>}
|
||||
value={selectedStatus}
|
||||
onChange={onChange}
|
||||
selectedLabel={selectedStatus && <DocumentStatus status={selectedStatus} className="[&>svg]:mr-1.5" />}
|
||||
options={selectableStatuses.map((value) => ({
|
||||
value,
|
||||
label: <DocumentStatus status={value} />,
|
||||
trailing: formatStatsCount(stats[value]),
|
||||
}))}
|
||||
testId="documents-table-status-filter"
|
||||
/>
|
||||
|
||||
{/* Visually hidden document counts, for screen readers and tests. */}
|
||||
<span className="sr-only" data-testid="documents-status-counts">
|
||||
{[...selectableStatuses, ExtendedDocumentStatus.ALL].map((value) => (
|
||||
<span key={value}>
|
||||
{_(FRIENDLY_STATUS_MAP[value].label)}:{' '}
|
||||
<span data-testid={`documents-status-count-${value}`}>{stats[value]}</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SELECTABLE_STATUSES: ExtendedDocumentStatus[] = [
|
||||
ExtendedDocumentStatus.INBOX,
|
||||
ExtendedDocumentStatus.PENDING,
|
||||
ExtendedDocumentStatus.COMPLETED,
|
||||
ExtendedDocumentStatus.CANCELLED,
|
||||
ExtendedDocumentStatus.DRAFT,
|
||||
ExtendedDocumentStatus.REJECTED,
|
||||
ExtendedDocumentStatus.EXPIRED,
|
||||
];
|
||||
|
||||
const formatStatsCount = (count: number) => {
|
||||
return count >= STATS_COUNT_CAP ? `${STATS_COUNT_CAP.toLocaleString()}+` : count.toString();
|
||||
};
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { FolderInputIcon, Trash2Icon, XCircleIcon, XIcon } from 'lucide-react';
|
||||
import { DownloadIcon, FolderInputIcon, Trash2Icon, XCircleIcon, XIcon } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export type EnvelopesTableBulkActionBarProps = {
|
||||
selectedCount: number;
|
||||
onDownloadClick?: () => void;
|
||||
onMoveClick: () => void;
|
||||
onDeleteClick: () => void;
|
||||
onCancelClick?: () => void;
|
||||
@@ -12,6 +14,7 @@ export type EnvelopesTableBulkActionBarProps = {
|
||||
|
||||
export const EnvelopesTableBulkActionBar = ({
|
||||
selectedCount,
|
||||
onDownloadClick,
|
||||
onMoveClick,
|
||||
onDeleteClick,
|
||||
onCancelClick,
|
||||
@@ -19,37 +22,106 @@ export const EnvelopesTableBulkActionBar = ({
|
||||
}: EnvelopesTableBulkActionBarProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
// Radix dismissable layers (dialogs, dropdowns, etc) call preventDefault
|
||||
// when handling Escape, so this only clears the selection when nothing
|
||||
// else consumed the key press.
|
||||
if (event.key === 'Escape' && !event.defaultPrevented) {
|
||||
onClearSelection();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [selectedCount, onClearSelection]);
|
||||
|
||||
if (selectedCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-4 rounded-lg border border-border bg-background px-4 py-3 shadow-lg">
|
||||
<span className="font-medium text-sm">
|
||||
<Trans>{selectedCount} selected</Trans>
|
||||
</span>
|
||||
<div className="fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-1 rounded-xl bg-popover p-1.5 text-popover-foreground shadow-lg ring-1 ring-black/10 dark:ring-white/10">
|
||||
<div className="flex items-center gap-x-2 px-2">
|
||||
<span className="sr-only" aria-live="polite">
|
||||
<Trans>{selectedCount} selected</Trans>
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-5 min-w-5 items-center justify-center rounded-md bg-primary px-1 font-semibold text-primary-foreground text-xs tabular-nums"
|
||||
>
|
||||
{selectedCount}
|
||||
</span>
|
||||
<span aria-hidden="true" className="font-medium text-foreground text-sm max-[420px]:hidden">
|
||||
<Trans>selected</Trans>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-border" />
|
||||
<div className="mx-1 h-5 w-px bg-border" />
|
||||
|
||||
<Button type="button" variant="outline" size="sm" onClick={onMoveClick}>
|
||||
<FolderInputIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Move to Folder</Trans>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onMoveClick}
|
||||
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
|
||||
>
|
||||
<FolderInputIcon className="size-4 shrink-0" />
|
||||
<Trans>Move</Trans>
|
||||
</Button>
|
||||
|
||||
{onDownloadClick && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDownloadClick}
|
||||
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
|
||||
>
|
||||
<DownloadIcon className="size-4 shrink-0" />
|
||||
<Trans>Download</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onCancelClick && (
|
||||
<Button type="button" variant="outline" size="sm" onClick={onCancelClick}>
|
||||
<XCircleIcon className="mr-2 h-4 w-4" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onCancelClick}
|
||||
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
|
||||
>
|
||||
<XCircleIcon className="size-4 shrink-0" />
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button type="button" variant="destructive" size="sm" onClick={onDeleteClick}>
|
||||
<Trash2Icon className="mr-2 h-4 w-4" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDeleteClick}
|
||||
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<Trash2Icon className="size-4 shrink-0" />
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="sm" onClick={onClearSelection} aria-label={t`Clear selection`}>
|
||||
<XIcon className="h-4 w-4" />
|
||||
<div className="mx-1 h-5 w-px bg-border" />
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClearSelection}
|
||||
aria-label={t`Clear selection`}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<XIcon className="size-4 shrink-0" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,59 +1,57 @@
|
||||
import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { STATS_COUNT_CAP } from '@documenso/lib/constants/document';
|
||||
import { SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types';
|
||||
import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/find-documents-internal.types';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType, FolderType, OrganisationType } from '@prisma/client';
|
||||
import { EnvelopeType, FolderType, type DocumentStatus as PrismaDocumentStatus } from '@prisma/client';
|
||||
import { XIcon } from 'lucide-react';
|
||||
import { useQueryStates } from 'nuqs';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
|
||||
import { EnvelopesBulkCancelDialog } from '~/components/dialogs/envelopes-bulk-cancel-dialog';
|
||||
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
|
||||
import {
|
||||
type EnvelopeBulkDownloadItem,
|
||||
EnvelopesBulkDownloadDialog,
|
||||
} from '~/components/dialogs/envelopes-bulk-download-dialog';
|
||||
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
|
||||
import { DocumentSearch } from '~/components/general/document/document-search';
|
||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||
import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper';
|
||||
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
||||
import { PeriodSelector } from '~/components/general/period-selector';
|
||||
import { DocumentsTable } from '~/components/tables/documents-table';
|
||||
import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state';
|
||||
import { DocumentsTablePeriodFilter } from '~/components/tables/documents-table-period-filter';
|
||||
import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter';
|
||||
import { DocumentsTableStatusFilter } from '~/components/tables/documents-table-status-filter';
|
||||
import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { documentsSearchParams } from '~/utils/documents-search-params';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags(msg`Documents`);
|
||||
}
|
||||
|
||||
const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
|
||||
status: true,
|
||||
period: true,
|
||||
page: true,
|
||||
perPage: true,
|
||||
query: true,
|
||||
}).extend({
|
||||
senderIds: z.string().transform(parseToIntegerArray).optional().catch([]),
|
||||
});
|
||||
type EnvelopeMetaCache = Record<string, { title: string; status: PrismaDocumentStatus; isLegacy: boolean }>;
|
||||
|
||||
// Stable initial values: `useSessionStorage` keeps its setter identity stable
|
||||
// only while the initial value reference is stable, and the metadata cache
|
||||
// effect below depends on that setter.
|
||||
const EMPTY_ROW_SELECTION: RowSelectionState = {};
|
||||
const EMPTY_ENVELOPE_META_CACHE: EnvelopeMetaCache = {};
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const organisation = useCurrentOrganisation();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { folderId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
@@ -61,9 +59,18 @@ export default function DocumentsPage() {
|
||||
const [isMovingDocument, setIsMovingDocument] = useState(false);
|
||||
const [documentToMove, setDocumentToMove] = useState<string | null>(null);
|
||||
|
||||
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>('documents-bulk-selection', {});
|
||||
// Scoped by team so selections made in one team never leak into another.
|
||||
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
|
||||
`documents-bulk-selection-${team.id}`,
|
||||
EMPTY_ROW_SELECTION,
|
||||
);
|
||||
const [envelopeMetaCache, setEnvelopeMetaCache] = useSessionStorage<EnvelopeMetaCache>(
|
||||
`documents-bulk-selection-meta-${team.id}`,
|
||||
EMPTY_ENVELOPE_META_CACHE,
|
||||
);
|
||||
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
|
||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
const [isBulkDownloadDialogOpen, setIsBulkDownloadDialogOpen] = useState(false);
|
||||
const [isBulkCancelDialogOpen, setIsBulkCancelDialogOpen] = useState(false);
|
||||
|
||||
const selectedEnvelopeIds = useMemo(() => {
|
||||
@@ -76,18 +83,23 @@ export default function DocumentsPage() {
|
||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||
[ExtendedDocumentStatus.REJECTED]: 0,
|
||||
[ExtendedDocumentStatus.CANCELLED]: 0,
|
||||
[ExtendedDocumentStatus.EXPIRED]: 0,
|
||||
[ExtendedDocumentStatus.INBOX]: 0,
|
||||
[ExtendedDocumentStatus.ALL]: 0,
|
||||
});
|
||||
|
||||
const findDocumentSearchParams = useMemo(
|
||||
() => ZSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {},
|
||||
[searchParams],
|
||||
);
|
||||
const [findDocumentSearchParams, setFindDocumentSearchParams] = useQueryStates(documentsSearchParams, {
|
||||
history: 'push',
|
||||
});
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.document.findDocumentsInternal.useQuery(
|
||||
{
|
||||
...findDocumentSearchParams,
|
||||
status: findDocumentSearchParams.status ?? undefined,
|
||||
period: findDocumentSearchParams.period ?? undefined,
|
||||
senderIds: findDocumentSearchParams.senderIds ?? undefined,
|
||||
page: findDocumentSearchParams.page ?? undefined,
|
||||
perPage: findDocumentSearchParams.perPage ?? undefined,
|
||||
query: findDocumentSearchParams.query ?? undefined,
|
||||
folderId,
|
||||
},
|
||||
{
|
||||
@@ -95,34 +107,66 @@ export default function DocumentsPage() {
|
||||
},
|
||||
);
|
||||
|
||||
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
useEffect(() => {
|
||||
setEnvelopeMetaCache((prev) => {
|
||||
const next: EnvelopeMetaCache = {};
|
||||
|
||||
params.set('status', value);
|
||||
for (const id of Object.keys(prev)) {
|
||||
if (rowSelection[id]) {
|
||||
next[id] = prev[id];
|
||||
}
|
||||
}
|
||||
|
||||
if (value === ExtendedDocumentStatus.ALL) {
|
||||
params.delete('status');
|
||||
}
|
||||
for (const document of data?.data ?? []) {
|
||||
if (rowSelection[document.envelopeId]) {
|
||||
next[document.envelopeId] = {
|
||||
title: document.title,
|
||||
status: document.status,
|
||||
isLegacy: document.internalVersion === 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (value === ExtendedDocumentStatus.INBOX && organisation.type === OrganisationType.PERSONAL) {
|
||||
params.delete('status');
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [data?.data, rowSelection, setEnvelopeMetaCache]);
|
||||
|
||||
if (params.has('page')) {
|
||||
params.delete('page');
|
||||
}
|
||||
const selectedEnvelopesForDownload = useMemo(() => {
|
||||
return selectedEnvelopeIds
|
||||
.map((id): EnvelopeBulkDownloadItem | null => {
|
||||
const meta = envelopeMetaCache[id];
|
||||
|
||||
let path = formatDocumentsPath(team.url);
|
||||
if (!meta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (folderId) {
|
||||
path += `/f/${folderId}`;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
title: meta.title,
|
||||
status: meta.status,
|
||||
// Stale cache entries predating this field are treated as legacy so
|
||||
// the Partial option is never offered without certainty.
|
||||
isLegacy: meta.isLegacy ?? true,
|
||||
};
|
||||
})
|
||||
.filter((item): item is EnvelopeBulkDownloadItem => item !== null);
|
||||
}, [selectedEnvelopeIds, envelopeMetaCache]);
|
||||
|
||||
if (params.toString()) {
|
||||
path += `?${params.toString()}`;
|
||||
}
|
||||
const hasActiveFilters = useMemo(() => {
|
||||
return Boolean(
|
||||
(findDocumentSearchParams.status && findDocumentSearchParams.status !== ExtendedDocumentStatus.ALL) ||
|
||||
findDocumentSearchParams.senderIds?.length ||
|
||||
findDocumentSearchParams.period,
|
||||
);
|
||||
}, [findDocumentSearchParams]);
|
||||
|
||||
return path;
|
||||
const onResetFilters = () => {
|
||||
void setFindDocumentSearchParams({
|
||||
status: null,
|
||||
senderIds: null,
|
||||
period: null,
|
||||
page: null,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -136,67 +180,40 @@ export default function DocumentsPage() {
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<FolderGrid type={FolderType.DOCUMENT} parentId={folderId ?? null} />
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
||||
<div className="flex flex-row items-center">
|
||||
<Avatar className="mr-3 h-12 w-12 border-2 border-white border-solid dark:border-border">
|
||||
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
|
||||
<AvatarFallback className="text-muted-foreground text-xs">{team.name.slice(0, 1)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="mt-8 flex flex-row items-center">
|
||||
<Avatar className="mr-3 h-12 w-12 border-2 border-white border-solid dark:border-border">
|
||||
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
|
||||
<AvatarFallback className="text-muted-foreground text-xs">{team.name.slice(0, 1)}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<h2 className="font-semibold text-4xl">
|
||||
<Trans>Documents</Trans>
|
||||
</h2>
|
||||
<h2 className="font-semibold text-4xl">
|
||||
<Trans>Documents</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center gap-x-2 gap-y-4">
|
||||
<div className="w-56">
|
||||
<DocumentSearch />
|
||||
</div>
|
||||
|
||||
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
||||
<Tabs value={findDocumentSearchParams.status || 'ALL'} className="overflow-x-auto">
|
||||
<TabsList>
|
||||
{[
|
||||
ExtendedDocumentStatus.INBOX,
|
||||
ExtendedDocumentStatus.PENDING,
|
||||
ExtendedDocumentStatus.COMPLETED,
|
||||
ExtendedDocumentStatus.CANCELLED,
|
||||
ExtendedDocumentStatus.DRAFT,
|
||||
ExtendedDocumentStatus.ALL,
|
||||
]
|
||||
.filter((value) => {
|
||||
if (organisation.type === OrganisationType.PERSONAL) {
|
||||
return value !== ExtendedDocumentStatus.INBOX;
|
||||
}
|
||||
<DocumentsTableStatusFilter stats={stats} />
|
||||
|
||||
return true;
|
||||
})
|
||||
.map((value) => (
|
||||
<TabsTrigger key={value} className="min-w-[60px] hover:text-foreground" value={value} asChild>
|
||||
<Link to={getTabHref(value)} preventScrollReset>
|
||||
<DocumentStatus status={value} />
|
||||
{team && <DocumentsTableSenderFilter teamId={team.id} />}
|
||||
|
||||
{value !== ExtendedDocumentStatus.ALL && (
|
||||
<span className="ml-1 inline-block opacity-50">
|
||||
{stats[value] >= STATS_COUNT_CAP ? `${STATS_COUNT_CAP.toLocaleString()}+` : stats[value]}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<DocumentsTablePeriodFilter />
|
||||
|
||||
{team && <DocumentsTableSenderFilter teamId={team.id} />}
|
||||
|
||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<PeriodSelector />
|
||||
</div>
|
||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<DocumentSearch initialValue={findDocumentSearchParams.query} />
|
||||
</div>
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" className="px-2 text-muted-foreground lg:px-3" onClick={onResetFilters}>
|
||||
<Trans>Reset</Trans>
|
||||
<XIcon className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<div>
|
||||
{data && data.count === 0 ? (
|
||||
<DocumentsTableEmptyState status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL} />
|
||||
<DocumentsTableEmptyState status={findDocumentSearchParams.status ?? ExtendedDocumentStatus.ALL} />
|
||||
) : (
|
||||
<DocumentsTable
|
||||
data={data}
|
||||
@@ -235,12 +252,28 @@ export default function DocumentsPage() {
|
||||
|
||||
<EnvelopesTableBulkActionBar
|
||||
selectedCount={selectedEnvelopeIds.length}
|
||||
onDownloadClick={() => setIsBulkDownloadDialogOpen(true)}
|
||||
onMoveClick={() => setIsBulkMoveDialogOpen(true)}
|
||||
onDeleteClick={() => setIsBulkDeleteDialogOpen(true)}
|
||||
onCancelClick={() => setIsBulkCancelDialogOpen(true)}
|
||||
onClearSelection={() => setRowSelection({})}
|
||||
/>
|
||||
|
||||
<EnvelopesBulkDownloadDialog
|
||||
envelopes={selectedEnvelopesForDownload}
|
||||
open={isBulkDownloadDialogOpen}
|
||||
onOpenChange={setIsBulkDownloadDialogOpen}
|
||||
onSuccess={(successfulEnvelopeIds) => {
|
||||
setRowSelection((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const id of successfulEnvelopeIds) {
|
||||
delete next[id];
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<EnvelopesBulkMoveDialog
|
||||
envelopeIds={selectedEnvelopeIds}
|
||||
envelopeType={EnvelopeType.DOCUMENT}
|
||||
|
||||
@@ -26,12 +26,14 @@ import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
const TEMPLATE_VIEWS = ['team', 'organisation'] as const;
|
||||
|
||||
type TemplateView = (typeof TEMPLATE_VIEWS)[number];
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags(msg`Templates`);
|
||||
}
|
||||
|
||||
// Stable initial value: `useSessionStorage` keeps its setter identity stable
|
||||
// only while the initial value reference is stable.
|
||||
const EMPTY_ROW_SELECTION: RowSelectionState = {};
|
||||
|
||||
export default function TemplatesPage() {
|
||||
const team = useCurrentTeam();
|
||||
const organisation = useCurrentOrganisation();
|
||||
@@ -47,7 +49,11 @@ export default function TemplatesPage() {
|
||||
const isOrgView = view === 'organisation';
|
||||
const showOrgTab = organisation.type !== OrganisationType.PERSONAL;
|
||||
|
||||
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>('templates-bulk-selection', {});
|
||||
// Scoped by team so selections made in one team never leak into another.
|
||||
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
|
||||
`templates-bulk-selection-${team.id}`,
|
||||
EMPTY_ROW_SELECTION,
|
||||
);
|
||||
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
|
||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import { parseAsArrayOf, parseAsInteger, parseAsString, parseAsStringLiteral } from 'nuqs';
|
||||
|
||||
export const DOCUMENTS_PERIOD_VALUES = ['7d', '14d', '30d'] as const;
|
||||
|
||||
/**
|
||||
* Shared nuqs parsers for the documents page URL state.
|
||||
*
|
||||
* Used by the documents page and its filter components so every consumer
|
||||
* parses and serialises the params identically.
|
||||
*/
|
||||
export const documentsSearchParams = {
|
||||
status: parseAsStringLiteral(Object.values(ExtendedDocumentStatus)),
|
||||
period: parseAsStringLiteral(DOCUMENTS_PERIOD_VALUES),
|
||||
senderIds: parseAsArrayOf(parseAsInteger),
|
||||
page: parseAsInteger,
|
||||
perPage: parseAsInteger,
|
||||
query: parseAsString,
|
||||
};
|
||||
Generated
+10
-3
@@ -22,6 +22,7 @@
|
||||
"@prisma/extension-read-replicas": "^0.4.1",
|
||||
"ai": "^5.0.104",
|
||||
"cron-parser": "^5.5.0",
|
||||
"fflate": "^0.8.3",
|
||||
"luxon": "^3.7.2",
|
||||
"patch-package": "^8.0.1",
|
||||
"posthog-node": "4.18.0",
|
||||
@@ -20080,9 +20081,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.4.8",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz",
|
||||
"integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==",
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-selector": {
|
||||
@@ -26685,6 +26686,12 @@
|
||||
"web-vitals": "^4.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/posthog-js/node_modules/fflate": {
|
||||
"version": "0.4.9",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz",
|
||||
"integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/posthog-node": {
|
||||
"version": "4.18.0",
|
||||
"resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-4.18.0.tgz",
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
"@prisma/extension-read-replicas": "^0.4.1",
|
||||
"ai": "^5.0.104",
|
||||
"cron-parser": "^5.5.0",
|
||||
"fflate": "^0.8.3",
|
||||
"luxon": "^3.7.2",
|
||||
"patch-package": "^8.0.1",
|
||||
"posthog-node": "4.18.0",
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
|
||||
import {
|
||||
DocumentStatus,
|
||||
DocumentVisibility,
|
||||
RecipientRole,
|
||||
SigningStatus,
|
||||
TeamMemberRole,
|
||||
} from '@documenso/prisma/client';
|
||||
import {
|
||||
seedBlankDocument,
|
||||
seedCompletedDocument,
|
||||
@@ -1560,3 +1566,307 @@ test.describe('Find Documents API - Adversarial: Cross-Team templateId', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../../../fixtures/authentication';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
const downloadUrl = (envelopeId: string, envelopeItemId: string, version: 'original' | 'signed' | 'pending') =>
|
||||
`${WEBAPP_BASE_URL}/api/files/envelope/${envelopeId}/envelopeItem/${envelopeItemId}/download/${version}`;
|
||||
|
||||
const seedOwnerWithDraft = async () => {
|
||||
const owner = await seedUser();
|
||||
|
||||
const draft = await seedDraftDocument(owner.user, owner.team.id, [], {
|
||||
createDocumentOptions: { title: 'File Download Auth Test' },
|
||||
});
|
||||
|
||||
return { owner, draft, draftItem: draft.envelopeItems[0] };
|
||||
};
|
||||
|
||||
test.describe('Envelope item file download endpoint authorization', () => {
|
||||
test('rejects an unauthenticated download request', async ({ request }) => {
|
||||
const { draft, draftItem } = await seedOwnerWithDraft();
|
||||
|
||||
const res = await request.get(downloadUrl(draft.id, draftItem.id, 'original'));
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('rejects a download request from a user outside the organisation', async ({ page }) => {
|
||||
const { draft, draftItem } = await seedOwnerWithDraft();
|
||||
const { user: outsider } = await seedUser();
|
||||
|
||||
await apiSignin({ page, email: outsider.email });
|
||||
|
||||
const res = await page.request.get(downloadUrl(draft.id, draftItem.id, 'original'));
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(403);
|
||||
});
|
||||
|
||||
test('returns 404 for a nonexistent envelope', async ({ page }) => {
|
||||
const { user } = await seedUser();
|
||||
|
||||
await apiSignin({ page, email: user.email });
|
||||
|
||||
const res = await page.request.get(
|
||||
downloadUrl('envelope_does_not_exist', 'envelope_item_does_not_exist', 'original'),
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('rejects a pending version download for a draft envelope', async ({ page }) => {
|
||||
const { owner, draft, draftItem } = await seedOwnerWithDraft();
|
||||
|
||||
await apiSignin({ page, email: owner.user.email });
|
||||
|
||||
const res = await page.request.get(downloadUrl(draft.id, draftItem.id, 'pending'));
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(400);
|
||||
});
|
||||
|
||||
test('rejects a pending version download for a legacy envelope', async ({ page }) => {
|
||||
const owner = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
// Default internalVersion is 1 (legacy).
|
||||
const pendingDocument = await seedPendingDocument(owner.user, owner.team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Legacy Pending Download Test' },
|
||||
});
|
||||
|
||||
const envelopeItem = pendingDocument.envelopeItems[0];
|
||||
|
||||
await apiSignin({ page, email: owner.user.email });
|
||||
|
||||
const res = await page.request.get(downloadUrl(pendingDocument.id, envelopeItem.id, 'pending'));
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(400);
|
||||
});
|
||||
|
||||
test('allows the owner to download their own document', async ({ page }) => {
|
||||
const { owner, draft, draftItem } = await seedOwnerWithDraft();
|
||||
|
||||
await apiSignin({ page, email: owner.user.email });
|
||||
|
||||
const res = await page.request.get(downloadUrl(draft.id, draftItem.id, 'original'));
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.headers()['content-type']).toContain('application/pdf');
|
||||
|
||||
const body = await res.body();
|
||||
|
||||
// %PDF magic bytes.
|
||||
expect(Array.from(body.subarray(0, 4))).toEqual([0x25, 0x50, 0x44, 0x46]);
|
||||
});
|
||||
|
||||
test('rejects a recipient-token download with an invalid token', async ({ request }) => {
|
||||
const { draftItem } = await seedOwnerWithDraft();
|
||||
|
||||
const res = await request.get(
|
||||
`${WEBAPP_BASE_URL}/api/files/token/invalid-token-12345/envelopeItem/${draftItem.id}/download/original`,
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import { createTeam } from '@documenso/lib/server-only/team/create-team';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedCompletedDocument, seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
|
||||
@@ -5,6 +7,7 @@ import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||
import { unzipSync } from 'fflate';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
@@ -50,10 +53,10 @@ test('[BULK_ACTIONS]: can select multiple documents with checkboxes', async ({ p
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('2 selected')).toBeVisible();
|
||||
await expect(page.getByText(/2\s*selected/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: header checkbox selects all documents on page', async ({ page }) => {
|
||||
@@ -67,7 +70,7 @@ test('[BULK_ACTIONS]: header checkbox selects all documents on page', async ({ p
|
||||
|
||||
await page.locator('thead').getByRole('checkbox').click();
|
||||
|
||||
await expect(page.getByText(`${documents.length} selected`)).toBeVisible();
|
||||
await expect(page.getByText(new RegExp(`${documents.length}\\s*selected`))).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
|
||||
@@ -80,11 +83,11 @@ test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
|
||||
});
|
||||
|
||||
await page.locator('thead').getByRole('checkbox').click();
|
||||
await expect(page.getByText(/\d+ selected/)).toBeVisible();
|
||||
await expect(page.getByText(/\d+\s*selected/)).toBeVisible();
|
||||
|
||||
await page.getByLabel('Clear selection').click();
|
||||
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page }) => {
|
||||
@@ -98,13 +101,13 @@ test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page })
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByText('Move Documents to Folder')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: folder.name }).click();
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||
|
||||
@@ -113,6 +116,122 @@ test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page })
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Doc 2' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: selection does not leak between teams', async ({ page }) => {
|
||||
const { sender } = await seedBulkActionsTestRequirements();
|
||||
|
||||
const teamBUrl = `team-b-${Date.now()}`;
|
||||
|
||||
await createTeam({
|
||||
userId: sender.user.id,
|
||||
teamName: 'Team B',
|
||||
teamUrl: teamBUrl,
|
||||
organisationId: sender.organisation.id,
|
||||
inheritMembers: true,
|
||||
});
|
||||
|
||||
const teamB = await prisma.team.findFirstOrThrow({
|
||||
where: { url: teamBUrl },
|
||||
});
|
||||
|
||||
await seedDraftDocument(sender.user, teamB.id, [], {
|
||||
createDocumentOptions: { title: 'Team B Doc' },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
|
||||
// The selection made in team A must not appear in team B.
|
||||
await page.goto(`/t/${teamBUrl}/documents`);
|
||||
await expect(page.getByRole('link', { name: 'Team B Doc' })).toBeVisible();
|
||||
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||
|
||||
// Returning to team A restores its selection.
|
||||
await page.goto(`/t/${sender.team.url}/documents`);
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: escape clears selection unless a dialog is open', async ({ page }) => {
|
||||
const { sender } = await seedBulkActionsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
|
||||
// Escape while a dialog is open should close the dialog but keep the selection.
|
||||
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
|
||||
// Escape with no dialog open should clear the selection.
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(page.getByText(/1\s*selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can bulk download multiple documents as a zip', async ({ page }) => {
|
||||
const { sender, documents } = await seedBulkActionsTestRequirements();
|
||||
|
||||
const [doc1, doc2] = documents;
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Download', exact: true }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByText('Download Documents')).toBeVisible();
|
||||
await expect(dialog.getByText('Bulk Test Doc 1')).toBeVisible();
|
||||
await expect(dialog.getByText('Bulk Test Doc 2')).toBeVisible();
|
||||
await expect(dialog.getByText('Draft').first()).toBeVisible();
|
||||
|
||||
const downloadPromise = page.waitForEvent('download', { timeout: 10_000 });
|
||||
|
||||
await dialog.getByRole('button', { name: 'Download' }).click();
|
||||
|
||||
const download = await downloadPromise;
|
||||
|
||||
expect(download.suggestedFilename()).toMatch(/^documenso-documents-\d{4}-\d{2}-\d{2}\.zip$/);
|
||||
|
||||
const downloadPath = await download.path();
|
||||
const zipContents = unzipSync(new Uint8Array(fs.readFileSync(downloadPath)));
|
||||
|
||||
// Each envelope's files are nested inside an `envelopeId_title` folder.
|
||||
expect(Object.keys(zipContents).sort()).toEqual(
|
||||
[`${doc1.id}_Bulk Test Doc 1/Bulk Test Doc 1.pdf`, `${doc2.id}_Bulk Test Doc 2/Bulk Test Doc 2.pdf`].sort(),
|
||||
);
|
||||
|
||||
// Each entry should be a valid non-empty PDF (%PDF magic bytes).
|
||||
for (const entry of Object.values(zipContents)) {
|
||||
expect(Array.from(entry.slice(0, 4))).toEqual([0x25, 0x50, 0x44, 0x46]);
|
||||
}
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Documents downloaded');
|
||||
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can delete multiple draft documents', async ({ page }) => {
|
||||
const { sender } = await seedBulkActionsTestRequirements();
|
||||
|
||||
@@ -152,14 +271,14 @@ test('[BULK_ACTIONS]: selection clears after successful move', async ({ page })
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||
await page.getByRole('button', { name: folder.name }).click();
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => {
|
||||
@@ -172,13 +291,13 @@ test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Documents deleted');
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => {
|
||||
@@ -199,7 +318,7 @@ test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) =
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
|
||||
@@ -236,14 +355,14 @@ test('[BULK_ACTIONS]: can move documents from folder to home (root)', async ({ p
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible();
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { expect, type Page, test } from '@playwright/test';
|
||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { checkDocumentTabCount } from '../fixtures/documents';
|
||||
import { checkDocumentCounts, selectDocumentStatusFilter } from '../fixtures/documents';
|
||||
import { expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
@@ -61,13 +61,10 @@ test('[DOCUMENTS]: cancelling a pending document keeps it in the owner dashboard
|
||||
await expectToastTextToBeVisible(page, 'Document cancelled');
|
||||
|
||||
// The document must remain in the dashboard, unlike deleting a pending document.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 0);
|
||||
await checkDocumentTabCount(page, 'Cancelled', 1);
|
||||
await checkDocumentTabCount(page, 'All', 1);
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 0, cancelled: 1, all: 1 });
|
||||
|
||||
// The cancelled document is still listed.
|
||||
await page.getByRole('tab', { name: 'Cancelled' }).click();
|
||||
await selectDocumentStatusFilter(page, 'Cancelled');
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
|
||||
|
||||
// The envelope status is persisted as CANCELLED.
|
||||
@@ -131,7 +128,7 @@ test('[DOCUMENTS]: a cancelled document can be deleted, hiding it from the owner
|
||||
await expectToastTextToBeVisible(page, 'Document cancelled');
|
||||
|
||||
// Delete the now-cancelled document. Being terminal, it should soft delete (hide).
|
||||
await page.getByRole('tab', { name: 'Cancelled' }).click();
|
||||
await selectDocumentStatusFilter(page, 'Cancelled');
|
||||
|
||||
const documentActionBtn = page
|
||||
.locator('tr', { hasText: 'Document 1 - Pending' })
|
||||
|
||||
@@ -3,7 +3,7 @@ import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { checkDocumentTabCount } from '../fixtures/documents';
|
||||
import { checkDocumentCounts } from '../fixtures/documents';
|
||||
import { expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
@@ -174,11 +174,7 @@ test('[DOCUMENTS]: deleting draft documents should permanently remove it', async
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible();
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 1);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 0);
|
||||
await checkDocumentTabCount(page, 'All', 2);
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 1, completed: 1, draft: 0, all: 2 });
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: deleting pending documents should permanently remove it', async ({ page }) => {
|
||||
@@ -207,11 +203,7 @@ test('[DOCUMENTS]: deleting pending documents should permanently remove it', asy
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 0);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'All', 2);
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 0, completed: 1, draft: 1, all: 2 });
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: deleting completed documents as an owner should hide it from only the owner', async ({ page }) => {
|
||||
@@ -239,11 +231,7 @@ test('[DOCUMENTS]: deleting completed documents as an owner should hide it from
|
||||
|
||||
// Check document counts.
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 1);
|
||||
await checkDocumentTabCount(page, 'Completed', 0);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'All', 2);
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 1, completed: 0, draft: 1, all: 2 });
|
||||
|
||||
// Sign into the recipient account.
|
||||
await apiSignout({ page });
|
||||
@@ -255,11 +243,7 @@ test('[DOCUMENTS]: deleting completed documents as an owner should hide it from
|
||||
|
||||
// Check document counts.
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).toBeVisible();
|
||||
await checkDocumentTabCount(page, 'Inbox', 1);
|
||||
await checkDocumentTabCount(page, 'Pending', 0);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 0);
|
||||
await checkDocumentTabCount(page, 'All', 2);
|
||||
await checkDocumentCounts(page, { inbox: 1, pending: 0, completed: 1, draft: 0, all: 2 });
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: deleting documents as a recipient should only hide it for them', async ({ page }) => {
|
||||
@@ -300,11 +284,7 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the
|
||||
// Check document counts.
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 0);
|
||||
await checkDocumentTabCount(page, 'Completed', 0);
|
||||
await checkDocumentTabCount(page, 'Draft', 0);
|
||||
await checkDocumentTabCount(page, 'All', 0);
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 0, completed: 0, draft: 0, all: 0 });
|
||||
|
||||
// Sign into the sender account.
|
||||
await apiSignout({ page });
|
||||
@@ -315,11 +295,7 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the
|
||||
});
|
||||
|
||||
// Check document counts for sender.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 1);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'All', 3);
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 1, completed: 1, draft: 1, all: 3 });
|
||||
|
||||
// Sign into the other recipient account.
|
||||
await apiSignout({ page });
|
||||
@@ -330,9 +306,5 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the
|
||||
});
|
||||
|
||||
// Check document counts for other recipient.
|
||||
await checkDocumentTabCount(page, 'Inbox', 1);
|
||||
await checkDocumentTabCount(page, 'Pending', 0);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 0);
|
||||
await checkDocumentTabCount(page, 'All', 2);
|
||||
await checkDocumentCounts(page, { inbox: 1, pending: 0, completed: 1, draft: 0, all: 2 });
|
||||
});
|
||||
|
||||
@@ -10,10 +10,17 @@ import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
|
||||
import { seedTeam, seedTeamEmail, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
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 { checkDocumentTabCount } from '../fixtures/documents';
|
||||
import { checkDocumentCounts, checkDocumentTabCount, toggleDocumentSenderFilter } from '../fixtures/documents';
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
@@ -54,10 +61,7 @@ test.describe('Find Documents UI - Personal Context', () => {
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
await checkDocumentTabCount(page, 'All', 3);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'Pending', 1);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentCounts(page, { draft: 1, pending: 1, completed: 1, all: 3 });
|
||||
});
|
||||
|
||||
test('received documents from other teams should NOT appear in personal context', async ({ page }) => {
|
||||
@@ -133,10 +137,9 @@ test.describe('Find Documents UI - Personal Context', () => {
|
||||
redirectPath: `/t/${ownerTeam.url}/documents`,
|
||||
});
|
||||
|
||||
// Inbox should be 0 since there's no team email and received docs are on sender's team
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
// Owner's own doc should still show in All
|
||||
await checkDocumentTabCount(page, 'All', 1);
|
||||
// Inbox should be 0 since there's no team email and received docs are on sender's team.
|
||||
// Owner's own doc should still show in All.
|
||||
await checkDocumentCounts(page, { inbox: 0, all: 1 });
|
||||
await expect(page.getByRole('link', { name: 'Owner Draft Control' })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -700,9 +703,8 @@ test.describe('Find Documents UI - Team with Team Email', () => {
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
// But pending should still show
|
||||
await checkDocumentTabCount(page, 'Pending', 1);
|
||||
// Inbox should be 0, but pending should still show.
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 1 });
|
||||
});
|
||||
|
||||
test('documents sent BY team email user should appear in team context', async ({ page }) => {
|
||||
@@ -803,12 +805,9 @@ test.describe('Find Documents UI - Data Isolation & No Leaking', () => {
|
||||
});
|
||||
|
||||
// UserA should see only their own docs
|
||||
await checkDocumentTabCount(page, 'All', 3);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentCounts(page, { draft: 1, completed: 1, all: 3 });
|
||||
|
||||
// Verify no B docs leaked
|
||||
await page.getByRole('tab', { name: 'All' }).click();
|
||||
await expect(page.getByRole('link', { name: 'A Own Draft' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'B Draft Private', exact: true })).not.toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'B Pending Private', exact: true })).not.toBeVisible();
|
||||
@@ -959,9 +958,9 @@ test.describe('Find Documents UI - Data Isolation & No Leaking', () => {
|
||||
redirectPath: `/t/${outsideTeam.url}/documents`,
|
||||
});
|
||||
|
||||
// Only the outside user's own draft should appear (cross-team docs are not visible)
|
||||
await checkDocumentTabCount(page, 'Inbox', 0); // No team email → 0
|
||||
await checkDocumentTabCount(page, 'All', 1); // Check All tab last so we can verify visible links
|
||||
// Only the outside user's own draft should appear (cross-team docs are not visible).
|
||||
// Inbox is 0 since there is no team email.
|
||||
await checkDocumentCounts(page, { inbox: 0, all: 1 });
|
||||
await expect(page.getByRole('link', { name: 'Outside Own Draft' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Team Doc For Outside User', exact: true })).not.toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Team Doc For Other User Only', exact: true })).not.toBeVisible();
|
||||
@@ -1006,12 +1005,10 @@ test.describe('Find Documents UI - Tab Counts Consistency', () => {
|
||||
redirectPath: `/t/${ownerTeam.url}/documents`,
|
||||
});
|
||||
|
||||
// Only owner's own docs appear (received docs are on sender's team)
|
||||
await checkDocumentTabCount(page, 'Draft', 2);
|
||||
await checkDocumentTabCount(page, 'Pending', 1);
|
||||
await checkDocumentTabCount(page, 'Inbox', 0); // No team email → inbox returns null → 0
|
||||
await checkDocumentTabCount(page, 'Completed', 1); // Only owned completed (received is on sender's team)
|
||||
await checkDocumentTabCount(page, 'All', 4); // 2 drafts + 1 pending + 1 completed
|
||||
// Only owner's own docs appear (received docs are on sender's team).
|
||||
// Inbox is 0 since there is no team email, and only the owned completed
|
||||
// doc counts (received is on sender's team). All = 2 drafts + 1 pending + 1 completed.
|
||||
await checkDocumentCounts(page, { inbox: 0, draft: 2, pending: 1, completed: 1, all: 4 });
|
||||
});
|
||||
|
||||
test('team context tab counts should be accurate with mixed documents', async ({ page }) => {
|
||||
@@ -1063,10 +1060,7 @@ test.describe('Find Documents UI - Tab Counts Consistency', () => {
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
await checkDocumentTabCount(page, 'Draft', 2);
|
||||
await checkDocumentTabCount(page, 'Pending', 1);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'All', 4);
|
||||
await checkDocumentCounts(page, { draft: 2, pending: 1, completed: 1, all: 4 });
|
||||
});
|
||||
|
||||
test('team with team email tab counts should include received documents', async ({ page }) => {
|
||||
@@ -1100,11 +1094,9 @@ test.describe('Find Documents UI - Tab Counts Consistency', () => {
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'Inbox', 1); // One pending doc received by team email (NOT_SIGNED)
|
||||
await checkDocumentTabCount(page, 'Pending', 1); // Own pending
|
||||
await checkDocumentTabCount(page, 'Completed', 1); // Received completed via email
|
||||
await checkDocumentTabCount(page, 'All', 4); // All of the above
|
||||
// Inbox = one pending doc received by team email (NOT_SIGNED), pending = own
|
||||
// pending, completed = received completed via email, all = all of the above.
|
||||
await checkDocumentCounts(page, { inbox: 1, draft: 1, pending: 1, completed: 1, all: 4 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1156,12 +1148,139 @@ test.describe('Find Documents UI - Sender Filter', () => {
|
||||
await checkDocumentTabCount(page, 'All', 3);
|
||||
|
||||
// Filter by member1
|
||||
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
||||
await page.getByRole('option', { name: member1.name ?? '' }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
await toggleDocumentSenderFilter(page, member1.name ?? '');
|
||||
|
||||
// Should only show member1's doc
|
||||
await checkDocumentTabCount(page, 'All', 1);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,116 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
export const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => {
|
||||
await page.getByRole('tab', { name: tabName }).click();
|
||||
type DocumentStatusCounts = {
|
||||
inbox?: number;
|
||||
pending?: number;
|
||||
completed?: number;
|
||||
draft?: number;
|
||||
cancelled?: number;
|
||||
rejected?: number;
|
||||
expired?: number;
|
||||
all?: number;
|
||||
};
|
||||
|
||||
if (tabName !== 'All') {
|
||||
await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString());
|
||||
const STATUS_KEYS = {
|
||||
inbox: 'INBOX',
|
||||
pending: 'PENDING',
|
||||
completed: 'COMPLETED',
|
||||
draft: 'DRAFT',
|
||||
cancelled: 'CANCELLED',
|
||||
rejected: 'REJECTED',
|
||||
expired: 'EXPIRED',
|
||||
all: 'ALL',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Check the counts for multiple document statuses in one go via the
|
||||
* visually hidden stats rendered alongside the status filter.
|
||||
*
|
||||
* When `all` is provided the status filter is also cleared and the
|
||||
* unfiltered table count (or empty state) is verified.
|
||||
*/
|
||||
export const checkDocumentCounts = async (page: Page, counts: DocumentStatusCounts) => {
|
||||
for (const [key, status] of Object.entries(STATUS_KEYS)) {
|
||||
const count = counts[key as keyof typeof STATUS_KEYS];
|
||||
|
||||
if (count === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await expect(page.getByTestId(`documents-status-count-${status}`)).toHaveText(count.toString());
|
||||
}
|
||||
|
||||
if (counts.all !== undefined) {
|
||||
await clearDocumentStatusFilter(page);
|
||||
|
||||
if (counts.all === 0) {
|
||||
await expect(page.getByTestId('empty-document-state')).toBeVisible();
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('data-table-count')).toContainText(`Showing ${counts.all}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Select a status in the documents status filter pill.
|
||||
*
|
||||
* No-op if the status is already selected, since selecting the active
|
||||
* option again would clear the filter.
|
||||
*/
|
||||
export const selectDocumentStatusFilter = async (page: Page, statusName: string) => {
|
||||
const currentStatus = new URL(page.url()).searchParams.get('status');
|
||||
|
||||
if (currentStatus === statusName.toUpperCase()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await page.getByTestId('documents-table-status-filter').click();
|
||||
await page.getByRole('option', { name: statusName }).click();
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle a sender in the documents sender filter pill.
|
||||
*
|
||||
* The sender filter is a multi select, so the popover stays open after
|
||||
* picking and is closed with Escape.
|
||||
*/
|
||||
export const toggleDocumentSenderFilter = async (page: Page, senderName: string) => {
|
||||
await page.getByTestId('documents-table-sender-filter').click();
|
||||
await page.getByRole('option', { name: senderName }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
await page.keyboard.press('Escape');
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear the documents status filter pill, returning to the "All" view.
|
||||
*/
|
||||
export const clearDocumentStatusFilter = async (page: Page) => {
|
||||
const currentStatus = new URL(page.url()).searchParams.get('status');
|
||||
|
||||
if (!currentStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
await page.getByTestId('documents-table-status-filter').click();
|
||||
await page.getByRole('option', { name: 'Clear' }).click();
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply a status filter (or 'All' to clear it) and verify both the hidden
|
||||
* stats count and the resulting table.
|
||||
*
|
||||
* The count is not asserted against the stats for 'All', since tests use it
|
||||
* with search queries applied which only the table respects.
|
||||
*/
|
||||
export const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => {
|
||||
if (tabName === 'All') {
|
||||
await clearDocumentStatusFilter(page);
|
||||
} else {
|
||||
await expect(page.getByTestId(`documents-status-count-${tabName.toUpperCase()}`)).toHaveText(count.toString());
|
||||
|
||||
await selectDocumentStatusFilter(page, tabName);
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { checkDocumentTabCount } from '../fixtures/documents';
|
||||
import { checkDocumentCounts, checkDocumentTabCount, toggleDocumentSenderFilter } from '../fixtures/documents';
|
||||
import { expectTextToBeVisible, expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
|
||||
|
||||
test('[TEAMS]: check team documents count', async ({ page }) => {
|
||||
@@ -20,23 +20,13 @@ test('[TEAMS]: check team documents count', async ({ page }) => {
|
||||
});
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 2);
|
||||
await checkDocumentTabCount(page, 'All', 5);
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 1, draft: 2, all: 5 });
|
||||
|
||||
// Apply filter.
|
||||
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
||||
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
await toggleDocumentSenderFilter(page, teamMember2.name ?? '');
|
||||
|
||||
// Check counts after filtering.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 0);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'All', 3);
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 0, draft: 1, all: 3 });
|
||||
|
||||
await apiSignout({ page });
|
||||
}
|
||||
@@ -115,23 +105,13 @@ test('[TEAMS]: check team documents count with internal team email', async ({ pa
|
||||
});
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentTabCount(page, 'Inbox', 2);
|
||||
await checkDocumentTabCount(page, 'Pending', 3);
|
||||
await checkDocumentTabCount(page, 'Completed', 3);
|
||||
await checkDocumentTabCount(page, 'Draft', 3);
|
||||
await checkDocumentTabCount(page, 'All', 11);
|
||||
await checkDocumentCounts(page, { inbox: 2, pending: 3, completed: 3, draft: 3, all: 11 });
|
||||
|
||||
// Apply filter.
|
||||
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
||||
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
await toggleDocumentSenderFilter(page, teamMember2.name ?? '');
|
||||
|
||||
// Check counts after filtering.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 0);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'All', 3);
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 0, draft: 1, all: 3 });
|
||||
|
||||
await apiSignout({ page });
|
||||
}
|
||||
@@ -202,23 +182,13 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa
|
||||
});
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentTabCount(page, 'Inbox', 3);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 2);
|
||||
await checkDocumentTabCount(page, 'Draft', 2);
|
||||
await checkDocumentTabCount(page, 'All', 9);
|
||||
await checkDocumentCounts(page, { inbox: 3, pending: 2, completed: 2, draft: 2, all: 9 });
|
||||
|
||||
// Apply filter.
|
||||
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
||||
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
await toggleDocumentSenderFilter(page, teamMember2.name ?? '');
|
||||
|
||||
// Check counts after filtering.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 0);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'All', 3);
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 0, draft: 1, all: 3 });
|
||||
});
|
||||
|
||||
test('[TEAMS]: resend pending team document', async ({ page }) => {
|
||||
@@ -273,11 +243,7 @@ test('[TEAMS]: delete draft team document', async ({ page }) => {
|
||||
});
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'All', 4);
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 1, draft: 1, all: 4 });
|
||||
|
||||
await apiSignout({ page });
|
||||
}
|
||||
@@ -316,11 +282,7 @@ test('[TEAMS]: delete pending team document', async ({ page }) => {
|
||||
});
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 1);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 2);
|
||||
await checkDocumentTabCount(page, 'All', 4);
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 1, completed: 1, draft: 2, all: 4 });
|
||||
|
||||
await apiSignout({ page });
|
||||
}
|
||||
@@ -359,11 +321,7 @@ test('[TEAMS]: delete completed team document', async ({ page }) => {
|
||||
});
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 0);
|
||||
await checkDocumentTabCount(page, 'Draft', 2);
|
||||
await checkDocumentTabCount(page, 'All', 4);
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 0, draft: 2, all: 4 });
|
||||
|
||||
await apiSignout({ page });
|
||||
}
|
||||
|
||||
@@ -49,10 +49,10 @@ test('[BULK_ACTIONS]: can select multiple templates with checkboxes', async ({ p
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('2 selected')).toBeVisible();
|
||||
await expect(page.getByText(/2\s*selected/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: header checkbox selects all templates on page', async ({ page }) => {
|
||||
@@ -66,7 +66,7 @@ test('[BULK_ACTIONS]: header checkbox selects all templates on page', async ({ p
|
||||
|
||||
await page.locator('thead').getByRole('checkbox').click();
|
||||
|
||||
await expect(page.getByText(`${templates.length} selected`)).toBeVisible();
|
||||
await expect(page.getByText(new RegExp(`${templates.length}\\s*selected`))).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
|
||||
@@ -79,11 +79,11 @@ test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
|
||||
});
|
||||
|
||||
await page.locator('thead').getByRole('checkbox').click();
|
||||
await expect(page.getByText(/\d+ selected/)).toBeVisible();
|
||||
await expect(page.getByText(/\d+\s*selected/)).toBeVisible();
|
||||
|
||||
await page.getByLabel('Clear selection').click();
|
||||
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can move multiple templates to a folder', async ({ page }) => {
|
||||
@@ -97,13 +97,13 @@ test('[BULK_ACTIONS]: can move multiple templates to a folder', async ({ page })
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByText('Move Templates to Folder')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: folder.name }).click();
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||
|
||||
@@ -151,14 +151,14 @@ test('[BULK_ACTIONS]: selection clears after successful move', async ({ page })
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||
await page.getByRole('button', { name: folder.name }).click();
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => {
|
||||
@@ -171,13 +171,13 @@ test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Templates deleted');
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => {
|
||||
@@ -199,7 +199,7 @@ test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) =
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
|
||||
@@ -236,14 +236,14 @@ test('[BULK_ACTIONS]: can move templates from folder to home (root)', async ({ p
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible();
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
await expect(page.getByText(/1\s*selected/)).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await page.getByRole('button', { name: 'Move', exact: true }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||
|
||||
|
||||
@@ -18,9 +18,9 @@
|
||||
"@playwright/test": "1.56.1",
|
||||
"@types/node": "^20",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"tsx": "^4.23.1",
|
||||
"pixelmatch": "^7.1.0",
|
||||
"pngjs": "^7.0.0"
|
||||
"pngjs": "^7.0.0",
|
||||
"tsx": "^4.23.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"start-server-and-test": "^2.1.3"
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { Zip, ZipPassThrough } from 'fflate';
|
||||
|
||||
export type ZipFileEntry = {
|
||||
/**
|
||||
* The path of the file within the archive. Forward slashes create folders.
|
||||
* Individual path segments should be sanitized with
|
||||
* {@link sanitizeZipPathSegment} when derived from user-controlled values.
|
||||
*/
|
||||
filename: string;
|
||||
data: Blob;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitizes a single path segment (folder or file name) for use inside a zip
|
||||
* archive, replacing characters that are path separators or invalid on
|
||||
* Windows extraction.
|
||||
*/
|
||||
export const sanitizeZipPathSegment = (segment: string): string => {
|
||||
const sanitized = segment
|
||||
.replace(/[\\/:*?"<>|\p{Cc}]/gu, '-')
|
||||
.trim()
|
||||
// Windows cannot extract folders or files ending with a dot.
|
||||
.replace(/\.+$/, '');
|
||||
|
||||
return sanitized || 'untitled';
|
||||
};
|
||||
|
||||
export type ZipWriter = {
|
||||
/**
|
||||
* Adds a file to the zip stream. Files are written incrementally so the
|
||||
* input blob can be garbage collected once this resolves.
|
||||
*/
|
||||
addFile: (entry: ZipFileEntry) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Finishes the zip stream and returns the archive as a blob.
|
||||
*/
|
||||
finalize: () => Blob;
|
||||
|
||||
/**
|
||||
* Discards the zip stream and any buffered output.
|
||||
*/
|
||||
abort: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* How many bytes of a blob to materialise into the JS heap per read. Blobs
|
||||
* (e.g. fetch responses) can be disk-backed by the browser, it is only
|
||||
* `arrayBuffer()` that forces them into memory, so we read in slices.
|
||||
*/
|
||||
const READ_SLICE_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Once this many bytes of zip output have accumulated in the JS heap they are
|
||||
* coalesced into an intermediate blob. Browsers can page blob storage to disk
|
||||
* under memory pressure, and the final `new Blob(parts)` composes parts by
|
||||
* reference, so this keeps the heap bounded regardless of archive size.
|
||||
*/
|
||||
const OUTPUT_COALESCE_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Creates an incremental client-side zip writer.
|
||||
*
|
||||
* Files are stored without compression (PDFs are already internally
|
||||
* compressed) and streamed through the archive as they are added, so peak JS
|
||||
* heap usage is bounded by roughly one read slice plus one output buffer
|
||||
* rather than the total size of the archive.
|
||||
*/
|
||||
export const createZipWriter = (): ZipWriter => {
|
||||
const usedNames = new Set<string>();
|
||||
|
||||
const outputParts: Blob[] = [];
|
||||
let pendingChunks: Uint8Array[] = [];
|
||||
let pendingSize = 0;
|
||||
|
||||
let zipError: Error | null = null;
|
||||
|
||||
const flushPendingChunks = () => {
|
||||
if (pendingChunks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
outputParts.push(new Blob(pendingChunks));
|
||||
pendingChunks = [];
|
||||
pendingSize = 0;
|
||||
};
|
||||
|
||||
// ZipPassThrough is synchronous (no workers), so output callbacks have
|
||||
// always fired by the time `push`/`end` return.
|
||||
const zipStream = new Zip((error, chunk, isFinal) => {
|
||||
if (error) {
|
||||
zipError = error;
|
||||
return;
|
||||
}
|
||||
|
||||
pendingChunks.push(chunk);
|
||||
pendingSize += chunk.length;
|
||||
|
||||
if (pendingSize >= OUTPUT_COALESCE_BYTES || isFinal) {
|
||||
flushPendingChunks();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Deduplicates filenames case-insensitively (Windows extraction is
|
||||
* case-insensitive) by appending " (n)" before the extension.
|
||||
*/
|
||||
const deduplicateFilename = (filename: string) => {
|
||||
const match = filename.match(/^(.*?)(\.[^./]+)?$/);
|
||||
|
||||
const baseName = match?.[1] ?? filename;
|
||||
const extension = match?.[2] ?? '';
|
||||
|
||||
let candidate = filename;
|
||||
let counter = 1;
|
||||
|
||||
while (usedNames.has(candidate.toLowerCase())) {
|
||||
candidate = `${baseName} (${counter})${extension}`;
|
||||
counter += 1;
|
||||
}
|
||||
|
||||
usedNames.add(candidate.toLowerCase());
|
||||
|
||||
return candidate;
|
||||
};
|
||||
|
||||
const addFile = async ({ filename, data }: ZipFileEntry) => {
|
||||
if (zipError) {
|
||||
throw zipError;
|
||||
}
|
||||
|
||||
const file = new ZipPassThrough(deduplicateFilename(filename));
|
||||
|
||||
zipStream.add(file);
|
||||
|
||||
for (let offset = 0; offset < data.size; offset += READ_SLICE_BYTES) {
|
||||
const slice = data.slice(offset, offset + READ_SLICE_BYTES);
|
||||
|
||||
file.push(new Uint8Array(await slice.arrayBuffer()));
|
||||
|
||||
if (zipError) {
|
||||
throw zipError;
|
||||
}
|
||||
}
|
||||
|
||||
file.push(new Uint8Array(0), true);
|
||||
|
||||
if (zipError) {
|
||||
throw zipError;
|
||||
}
|
||||
};
|
||||
|
||||
const finalize = () => {
|
||||
zipStream.end();
|
||||
|
||||
if (zipError) {
|
||||
throw zipError;
|
||||
}
|
||||
|
||||
flushPendingChunks();
|
||||
|
||||
return new Blob(outputParts, { type: 'application/zip' });
|
||||
};
|
||||
|
||||
const abort = () => {
|
||||
zipStream.terminate();
|
||||
|
||||
pendingChunks = [];
|
||||
pendingSize = 0;
|
||||
outputParts.length = 0;
|
||||
};
|
||||
|
||||
return {
|
||||
addFile,
|
||||
finalize,
|
||||
abort,
|
||||
};
|
||||
};
|
||||
@@ -32,7 +32,11 @@ const versionToFilenameSuffix = (version: DocumentVersion): string => {
|
||||
}
|
||||
};
|
||||
|
||||
export const downloadPDF = async ({ envelopeItem, token, fileName, version = 'signed' }: DownloadPDFProps) => {
|
||||
/**
|
||||
* Fetches a PDF for an envelope item and returns it as a blob alongside the
|
||||
* filename it should be saved as. Throws on non-OK responses.
|
||||
*/
|
||||
export const fetchPDF = async ({ envelopeItem, token, fileName, version = 'signed' }: DownloadPDFProps) => {
|
||||
const downloadUrl = getEnvelopeItemPdfUrl({
|
||||
type: 'download',
|
||||
envelopeItem: envelopeItem,
|
||||
@@ -40,12 +44,27 @@ export const downloadPDF = async ({ envelopeItem, token, fileName, version = 'si
|
||||
version,
|
||||
});
|
||||
|
||||
const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
|
||||
const response = await fetch(downloadUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download PDF: ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
|
||||
const baseTitle = (fileName ?? 'document').replace(/\.pdf$/, '');
|
||||
|
||||
downloadFile({
|
||||
return {
|
||||
filename: `${baseTitle}${versionToFilenameSuffix(version)}`,
|
||||
blob,
|
||||
};
|
||||
};
|
||||
|
||||
export const downloadPDF = async (options: DownloadPDFProps) => {
|
||||
const { filename, blob } = await fetchPDF(options);
|
||||
|
||||
downloadFile({
|
||||
filename,
|
||||
data: blob,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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.PENDING]: 0,
|
||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { match } from 'ts-pattern';
|
||||
|
||||
import type { FindResultResponse } from '../../types/search-params';
|
||||
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
||||
import { hasExpiredRecipient } from '../envelope/query-helpers';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
|
||||
@@ -36,6 +37,11 @@ export type FindDocumentsOptions = {
|
||||
senderIds?: number[];
|
||||
query?: 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 false, use a full COUNT(*) for exact totals — preferred for external API consumers.
|
||||
@@ -115,6 +121,7 @@ export const findDocuments = async ({
|
||||
senderIds,
|
||||
query = '',
|
||||
folderId,
|
||||
hasExpiredRecipients,
|
||||
useWindowedCount = true,
|
||||
}: FindDocumentsOptions) => {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -455,6 +476,18 @@ export const findDocuments = async ({
|
||||
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();
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DateTime } from 'luxon';
|
||||
|
||||
import { STATS_COUNT_CAP } from '../../constants/document';
|
||||
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||
import { hasExpiredRecipient } from '../envelope/query-helpers';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
// 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)]);
|
||||
});
|
||||
|
||||
// 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
|
||||
// Returns 0 if the team has no team email.
|
||||
const inboxQuery = teamEmail
|
||||
@@ -274,15 +288,17 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
|
||||
|
||||
// ─── 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(pendingQuery),
|
||||
cappedCount(completedQuery),
|
||||
cappedCount(rejectedQuery),
|
||||
cappedCount(cancelledQuery),
|
||||
cappedCount(expiredQuery),
|
||||
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 stats: Record<ExtendedDocumentStatus, number> = {
|
||||
@@ -291,6 +307,7 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
|
||||
[ExtendedDocumentStatus.COMPLETED]: completed,
|
||||
[ExtendedDocumentStatus.REJECTED]: rejected,
|
||||
[ExtendedDocumentStatus.CANCELLED]: cancelled,
|
||||
[ExtendedDocumentStatus.EXPIRED]: expired,
|
||||
[ExtendedDocumentStatus.INBOX]: inbox,
|
||||
[ExtendedDocumentStatus.ALL]: all,
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||
import type { FindResultResponse } from '../../types/search-params';
|
||||
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
import { hasExpiredRecipient } from './query-helpers';
|
||||
|
||||
export type FindEnvelopesOptions = {
|
||||
userId: number;
|
||||
@@ -23,6 +24,11 @@ export type FindEnvelopesOptions = {
|
||||
};
|
||||
query?: 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 false, use a full COUNT(*) for exact totals — preferred for external API consumers.
|
||||
@@ -106,6 +112,7 @@ export const findEnvelopes = async ({
|
||||
orderBy,
|
||||
query = '',
|
||||
folderId,
|
||||
hasExpiredRecipients,
|
||||
useWindowedCount = true,
|
||||
}: FindEnvelopesOptions) => {
|
||||
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 ──────────────────────────────────────────────────
|
||||
//
|
||||
// An envelope is visible if ANY of:
|
||||
|
||||
@@ -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')),
|
||||
);
|
||||
@@ -310,6 +310,9 @@ export const seedDraftDocument = async (
|
||||
|
||||
const documentId = await incrementDocumentId();
|
||||
|
||||
const envelopeTitle =
|
||||
typeof createDocumentOptions.title === 'string' ? createDocumentOptions.title : `[TEST] Document ${key} - Draft`;
|
||||
|
||||
const document = await prisma.envelope.create({
|
||||
data: {
|
||||
id: prefixedId('envelope'),
|
||||
@@ -320,12 +323,12 @@ export const seedDraftDocument = async (
|
||||
documentMetaId: documentMeta.id,
|
||||
source: DocumentSource.DOCUMENT,
|
||||
teamId,
|
||||
title: `[TEST] Document ${key} - Draft`,
|
||||
title: envelopeTitle,
|
||||
status: DocumentStatus.DRAFT,
|
||||
envelopeItems: {
|
||||
create: {
|
||||
id: prefixedId('envelope_item'),
|
||||
title: `[TEST] Document ${key} - Draft`,
|
||||
title: envelopeTitle,
|
||||
documentDataId: documentData.id,
|
||||
order: 1,
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ export const ExtendedDocumentStatus = {
|
||||
...DocumentStatus,
|
||||
INBOX: 'INBOX',
|
||||
ALL: 'ALL',
|
||||
EXPIRED: 'EXPIRED',
|
||||
} as const;
|
||||
|
||||
export type ExtendedDocumentStatus = (typeof ExtendedDocumentStatus)[keyof typeof ExtendedDocumentStatus];
|
||||
|
||||
@@ -23,6 +23,7 @@ export const findDocumentsInternalRoute = authenticatedProcedure
|
||||
orderByColumn,
|
||||
source,
|
||||
status,
|
||||
hasExpiredRecipients,
|
||||
period,
|
||||
senderIds,
|
||||
folderId,
|
||||
@@ -49,6 +50,7 @@ export const findDocumentsInternalRoute = authenticatedProcedure
|
||||
period,
|
||||
senderIds,
|
||||
folderId,
|
||||
hasExpiredRecipients,
|
||||
orderBy: orderByColumn ? { column: orderByColumn, direction: orderByDirection } : undefined,
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -20,6 +20,7 @@ export const ZFindDocumentsInternalResponseSchema = ZFindResultResponse.extend({
|
||||
[ExtendedDocumentStatus.COMPLETED]: z.number(),
|
||||
[ExtendedDocumentStatus.REJECTED]: z.number(),
|
||||
[ExtendedDocumentStatus.CANCELLED]: z.number(),
|
||||
[ExtendedDocumentStatus.EXPIRED]: z.number(),
|
||||
[ExtendedDocumentStatus.INBOX]: z.number(),
|
||||
[ExtendedDocumentStatus.ALL]: z.number(),
|
||||
}),
|
||||
|
||||
@@ -11,7 +11,18 @@ export const findDocumentsRoute = authenticatedProcedure
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { user, teamId } = ctx;
|
||||
|
||||
const { query, templateId, page, perPage, orderByDirection, orderByColumn, source, status, folderId } = input;
|
||||
const {
|
||||
query,
|
||||
templateId,
|
||||
page,
|
||||
perPage,
|
||||
orderByDirection,
|
||||
orderByColumn,
|
||||
source,
|
||||
status,
|
||||
hasExpiredRecipients,
|
||||
folderId,
|
||||
} = input;
|
||||
|
||||
const documents = await findDocuments({
|
||||
userId: user.id,
|
||||
@@ -20,6 +31,7 @@ export const findDocumentsRoute = authenticatedProcedure
|
||||
query,
|
||||
source,
|
||||
status,
|
||||
hasExpiredRecipients,
|
||||
page,
|
||||
perPage,
|
||||
folderId,
|
||||
|
||||
@@ -21,6 +21,11 @@ export const ZFindDocumentsRequestSchema = ZFindSearchParamsSchema.extend({
|
||||
templateId: z.number().describe('Filter documents by the template ID used to create it.').optional(),
|
||||
source: z.nativeEnum(DocumentSource).describe('Filter documents by how it was created.').optional(),
|
||||
status: z.nativeEnum(DocumentStatus).describe('Filter documents by the current status').optional(),
|
||||
hasExpiredRecipients: z
|
||||
.enum(['true', 'false'])
|
||||
.describe('Filter for documents that have at least one recipient whose signing link has expired.')
|
||||
.transform((value) => value === 'true')
|
||||
.optional(),
|
||||
folderId: z.string().describe('Filter documents by folder ID').optional(),
|
||||
orderByColumn: z.enum(['createdAt']).optional(),
|
||||
orderByDirection: z.enum(['asc', 'desc']).describe('').default('desc'),
|
||||
|
||||
@@ -9,7 +9,7 @@ export const redistributeDocumentMeta: TrpcRouteMeta = {
|
||||
path: '/document/redistribute',
|
||||
summary: 'Redistribute document',
|
||||
description:
|
||||
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Redistribute the document to the provided recipients who have not actioned the document. Will use the distribution method set in the document',
|
||||
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Redistribute the document to the provided recipients who have not actioned the document. Will use the distribution method set in the document. This also refreshes the signing-link expiration for the targeted unsigned recipients, renewing any expired links.',
|
||||
tags: ['Document'],
|
||||
deprecated: true,
|
||||
},
|
||||
|
||||
@@ -10,7 +10,19 @@ export const findEnvelopesRoute = authenticatedProcedure
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { user, teamId } = ctx;
|
||||
|
||||
const { query, type, templateId, page, perPage, orderByDirection, orderByColumn, source, status, folderId } = input;
|
||||
const {
|
||||
query,
|
||||
type,
|
||||
templateId,
|
||||
page,
|
||||
perPage,
|
||||
orderByDirection,
|
||||
orderByColumn,
|
||||
source,
|
||||
status,
|
||||
hasExpiredRecipients,
|
||||
folderId,
|
||||
} = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
@@ -19,6 +31,7 @@ export const findEnvelopesRoute = authenticatedProcedure
|
||||
templateId,
|
||||
source,
|
||||
status,
|
||||
hasExpiredRecipients,
|
||||
folderId,
|
||||
page,
|
||||
perPage,
|
||||
@@ -33,6 +46,7 @@ export const findEnvelopesRoute = authenticatedProcedure
|
||||
query,
|
||||
source,
|
||||
status,
|
||||
hasExpiredRecipients,
|
||||
page,
|
||||
perPage,
|
||||
folderId,
|
||||
|
||||
@@ -20,6 +20,11 @@ export const ZFindEnvelopesRequestSchema = ZFindSearchParamsSchema.extend({
|
||||
templateId: z.number().describe('Filter envelopes by the template ID used to create it.').optional(),
|
||||
source: z.nativeEnum(DocumentSource).describe('Filter envelopes by how it was created.').optional(),
|
||||
status: z.nativeEnum(DocumentStatus).describe('Filter envelopes by the current status.').optional(),
|
||||
hasExpiredRecipients: z
|
||||
.enum(['true', 'false'])
|
||||
.describe('Filter for envelopes that have at least one recipient whose signing link has expired.')
|
||||
.transform((value) => value === 'true')
|
||||
.optional(),
|
||||
folderId: z.string().describe('Filter envelopes by folder ID.').optional(),
|
||||
orderByColumn: z.enum(['createdAt']).optional(),
|
||||
orderByDirection: z.enum(['asc', 'desc']).describe('Sort direction.').default('desc'),
|
||||
|
||||
@@ -10,7 +10,7 @@ export const redistributeEnvelopeMeta: TrpcRouteMeta = {
|
||||
path: '/envelope/redistribute',
|
||||
summary: 'Redistribute envelope',
|
||||
description:
|
||||
'Redistribute the envelope to the provided recipients who have not actioned the envelope. Will use the distribution method set in the envelope',
|
||||
'Redistribute the envelope to the provided recipients who have not actioned the envelope. Will use the distribution method set in the envelope. This also refreshes the signing-link expiration for the targeted unsigned recipients, renewing any expired links.',
|
||||
tags: ['Envelope'],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { createEnvelopeFields } from '@documenso/lib/server-only/field/create-envelope-fields';
|
||||
import { deleteDocumentField } from '@documenso/lib/server-only/field/delete-document-field';
|
||||
import { deleteTemplateField } from '@documenso/lib/server-only/field/delete-template-field';
|
||||
@@ -613,23 +614,37 @@ export const fieldRouter = router({
|
||||
* @private
|
||||
*/
|
||||
signFieldWithToken: procedure.input(ZSignFieldWithTokenMutationSchema).mutation(async ({ input, ctx }) => {
|
||||
const { token, fieldId, value, isBase64, authOptions } = input;
|
||||
try {
|
||||
const { token, fieldId, value, isBase64, authOptions } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
fieldId,
|
||||
},
|
||||
});
|
||||
|
||||
return await signFieldWithToken({
|
||||
token,
|
||||
fieldId,
|
||||
},
|
||||
});
|
||||
value: value ?? '',
|
||||
isBase64,
|
||||
userId: ctx.user?.id,
|
||||
authOptions,
|
||||
requestMetadata: ctx.metadata.requestMetadata,
|
||||
});
|
||||
} catch (err) {
|
||||
// Log the error for debugging purposes.
|
||||
ctx.logger.error({
|
||||
message: 'Error signing field with token',
|
||||
error: err instanceof AppError ? `[${err.code}]: ${err.message}` : String(err),
|
||||
});
|
||||
|
||||
return await signFieldWithToken({
|
||||
token,
|
||||
fieldId,
|
||||
value: value ?? '',
|
||||
isBase64,
|
||||
userId: ctx.user?.id,
|
||||
authOptions,
|
||||
requestMetadata: ctx.metadata.requestMetadata,
|
||||
});
|
||||
// Raw console.log incase we're somehow deailing with a funky error object that doesn't serialize well.
|
||||
console.log('Error signing field with token', err);
|
||||
|
||||
// Rethrow the error so that the client receives the appropriate error response.
|
||||
throw err;
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -638,18 +653,31 @@ export const fieldRouter = router({
|
||||
removeSignedFieldWithToken: procedure
|
||||
.input(ZRemovedSignedFieldWithTokenMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { token, fieldId } = input;
|
||||
try {
|
||||
const { token, fieldId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
fieldId,
|
||||
},
|
||||
});
|
||||
|
||||
return await removeSignedFieldWithToken({
|
||||
token,
|
||||
fieldId,
|
||||
},
|
||||
});
|
||||
requestMetadata: ctx.metadata.requestMetadata,
|
||||
});
|
||||
} catch (err) {
|
||||
// Log the error for debugging purposes.
|
||||
ctx.logger.error({
|
||||
message: 'Error removing signed field with token',
|
||||
error: err instanceof AppError ? `[${err.code}]: ${err.message}` : String(err),
|
||||
});
|
||||
|
||||
return await removeSignedFieldWithToken({
|
||||
token,
|
||||
fieldId,
|
||||
requestMetadata: ctx.metadata.requestMetadata,
|
||||
});
|
||||
console.log('Error removing signed field with token', err);
|
||||
|
||||
// Rethrow the error so that the client receives the appropriate error response.
|
||||
throw err;
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prepareCscRecipientSigning } from '@documenso/ee/server-only/signing/csc/prepare-recipient-signing';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
|
||||
import { rejectDocumentWithToken } from '@documenso/lib/server-only/document/reject-document-with-token';
|
||||
import { createEnvelopeRecipients } from '@documenso/lib/server-only/recipient/create-envelope-recipients';
|
||||
@@ -11,7 +12,6 @@ import { isTspEnvelope } from '@documenso/lib/types/signature-level';
|
||||
import { unsafeBuildEnvelopeIdQuery } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../schema';
|
||||
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||
import { findRecipientSuggestionsRoute } from './find-recipient-suggestions';
|
||||
@@ -590,47 +590,61 @@ export const recipientRouter = router({
|
||||
.input(ZCompleteDocumentWithTokenMutationSchema)
|
||||
.output(ZCompleteDocumentWithTokenResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { token, documentId, accessAuthOptions, nextSigner, recipientOverride } = input;
|
||||
try {
|
||||
const { token, documentId, accessAuthOptions, nextSigner, recipientOverride } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
documentId,
|
||||
},
|
||||
});
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
documentId,
|
||||
},
|
||||
});
|
||||
|
||||
// Branch on TSP envelopes before any SES side effects: TSP recipients
|
||||
// can't complete via this route — they go through the CSC sync sign
|
||||
// flow (`enterprise.csc.signEnvelope`). This route returns the redirect URL
|
||||
// for the credential-scope OAuth round-trip.
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
...unsafeBuildEnvelopeIdQuery({ type: 'documentId', id: documentId }, EnvelopeType.DOCUMENT),
|
||||
recipients: { some: { token } },
|
||||
},
|
||||
select: { signatureLevel: true, internalVersion: true },
|
||||
});
|
||||
// Branch on TSP envelopes before any SES side effects: TSP recipients
|
||||
// can't complete via this route — they go through the CSC sync sign
|
||||
// flow (`enterprise.csc.signEnvelope`). This route returns the redirect URL
|
||||
// for the credential-scope OAuth round-trip.
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
...unsafeBuildEnvelopeIdQuery({ type: 'documentId', id: documentId }, EnvelopeType.DOCUMENT),
|
||||
recipients: { some: { token } },
|
||||
},
|
||||
select: { signatureLevel: true, internalVersion: true },
|
||||
});
|
||||
|
||||
if (isTspEnvelope(envelope)) {
|
||||
return await prepareCscRecipientSigning({
|
||||
recipientToken: token,
|
||||
if (isTspEnvelope(envelope)) {
|
||||
return await prepareCscRecipientSigning({
|
||||
recipientToken: token,
|
||||
requestMetadata: ctx.metadata.requestMetadata,
|
||||
});
|
||||
}
|
||||
|
||||
await completeDocumentWithToken({
|
||||
token,
|
||||
id: {
|
||||
type: 'documentId',
|
||||
id: documentId,
|
||||
},
|
||||
accessAuthOptions,
|
||||
nextSigner,
|
||||
recipientOverride,
|
||||
userId: ctx.user?.id,
|
||||
requestMetadata: ctx.metadata.requestMetadata,
|
||||
});
|
||||
|
||||
return { status: 'SIGNED' as const };
|
||||
} catch (err) {
|
||||
// Log the error for debugging purposes.
|
||||
ctx.logger.error({
|
||||
message: 'Error completing document with token',
|
||||
error: err instanceof AppError ? `[${err.code}]: ${err.message}` : String(err),
|
||||
});
|
||||
|
||||
// Raw console.log incase we're somehow dealing with a funky error object that doesn't serialize well.
|
||||
console.log('Error completing document with token', err);
|
||||
|
||||
// Rethrow the error so that the client receives the appropriate error response.
|
||||
throw err;
|
||||
}
|
||||
|
||||
await completeDocumentWithToken({
|
||||
token,
|
||||
id: {
|
||||
type: 'documentId',
|
||||
id: documentId,
|
||||
},
|
||||
accessAuthOptions,
|
||||
nextSigner,
|
||||
recipientOverride,
|
||||
userId: ctx.user?.id,
|
||||
requestMetadata: ctx.metadata.requestMetadata,
|
||||
});
|
||||
|
||||
return { status: 'SIGNED' as const };
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,4 +35,43 @@ const RadioGroupItem = React.forwardRef<
|
||||
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
/**
|
||||
* A segmented-control style radio group where each item renders as a small
|
||||
* toggle button rather than a radio circle.
|
||||
*/
|
||||
const RadioGroupSegmented = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn('inline-flex items-center gap-0.5 rounded-md bg-muted p-0.5', className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
RadioGroupSegmented.displayName = 'RadioGroupSegmented';
|
||||
|
||||
const RadioGroupSegmentedItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-sm px-2 py-0.5 font-medium text-muted-foreground text-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-background data-[state=checked]:text-foreground data-[state=checked]:shadow-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
});
|
||||
|
||||
RadioGroupSegmentedItem.displayName = 'RadioGroupSegmentedItem';
|
||||
|
||||
export { RadioGroup, RadioGroupItem, RadioGroupSegmented, RadioGroupSegmentedItem };
|
||||
|
||||
Reference in New Issue
Block a user