diff --git a/.agents/plans/wild-indigo-wave-rejected-expired-recipient-filters.md b/.agents/plans/wild-indigo-wave-rejected-expired-recipient-filters.md new file mode 100644 index 000000000..b8e9fb39c --- /dev/null +++ b/.agents/plans/wild-indigo-wave-rejected-expired-recipient-filters.md @@ -0,0 +1,146 @@ +--- +date: 2026-05-28 +title: Rejected Expired Recipient Filters +--- + +## Context + +Customers need to find (a) envelopes/documents in the `REJECTED` state and (b) envelopes +with at least one recipient whose signing link has **expired**. Today the UI only exposes +`INBOX / PENDING / COMPLETED / DRAFT / ALL` tabs, and the public API has no way to filter by +expired recipient links — forcing a fetch-all-`PENDING`-then-inspect-each-recipient workaround. + +Two key facts from exploration shaped this plan: + +- **`REJECTED` is already fully wired in the backend** — the where-clause (`find-documents.ts`), + stats counts (`get-stats.ts`), tRPC response schema, `ExtendedDocumentStatus` enum, and the + `FRIENDLY_STATUS_MAP` display all handle it. It is simply absent from the UI tab array. +- **Renewing expired links already works.** `resendDocument` refreshes `expiresAt` and clears + `expirationNotifiedAt` for unsigned, non-CC recipients (`resend-document.ts:98-121`), exposed + publicly via `POST /api/v2/document/redistribute` and `/api/v2/envelope/redistribute` and via the + resend/redistribute UI dialogs. No new renew mechanism is needed — only documentation/wording. + +Expiration is a per-recipient condition (not an envelope status). The approved design models it +in the UI as an `EXPIRED` **pseudo-status tab** (reusing the existing tab machinery, mirroring how +`REJECTED` works) and in the public API as an orthogonal boolean `hasExpiredRecipients`. Both share +one EXISTS predicate. + +Definition of "expired recipient" (matches `isRecipientExpired`, `packages/lib/utils/recipients.ts:118`): +a `Recipient` with `expiresAt IS NOT NULL AND expiresAt <= now() AND signingStatus = NOT_SIGNED AND role != CC`. + +## Approach + +### A. Shared EXISTS predicate (reused 4x, justified) +Add a local `hasExpiredRecipient(eb)` helper — modeled on the existing per-file `recipientExists` / +`senderEmailIs` helpers — to `find-documents.ts`, `get-stats.ts`, and `find-envelopes.ts`. It is the +single source of truth for the expired condition above (using `new Date()` for `now`, matching the +`period` filter's `.toJSDate()` style). + +### B. REJECTED tab (UI only — backend already done) +- `apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx`: add + `ExtendedDocumentStatus.REJECTED` to the tab array (lines 149-155). Count badge, highlight, and + `?status=REJECTED` filtering already work via existing machinery. + +### C. EXPIRED pseudo-status (UI + internal stats) +1. `packages/prisma/types/extended-document-status.ts`: add `EXPIRED: 'EXPIRED'`. Internal-only — + the public `DocumentStatus` enum is unaffected. This intentionally surfaces TS errors at the three + exhaustive/`Record` sites below, forcing them to be handled. +2. `packages/lib/server-only/document/find-documents.ts`: + - Add `.with(ExtendedDocumentStatus.EXPIRED, ...)` to **both** `applyPersonalFilters` and + `applyTeamFilters`, mirroring the `COMPLETED` branch's access control (deleted + visibility + + owner/recipient access) with `hasExpiredRecipient(eb)` AND-ed in. Do **not** constrain + `Envelope.status` — the EXISTS already restricts to unsigned recipients. +3. `packages/lib/server-only/document/get-stats.ts`: + - Add an `expiredQuery` mirroring `pendingQuery`'s access control + `hasExpiredRecipient(eb)`. + - Add it to the `Promise.all`, add `[ExtendedDocumentStatus.EXPIRED]: expired` to the `stats` + record. **Do not** add `expired` to the `all` sum (it overlaps `PENDING`). +4. `packages/trpc/server/document-router/find-documents-internal.types.ts`: add + `[ExtendedDocumentStatus.EXPIRED]: z.number()` to the `stats` response object. (`status` already + accepts the extended enum via `z.nativeEnum(ExtendedDocumentStatus)`.) +5. `apps/remix/app/components/general/document/document-status.tsx`: add an `EXPIRED` entry to + `FRIENDLY_STATUS_MAP` — `label: msg` Expired, an icon (e.g. lucide `TimerOff`, matching the + `/sign/$token/expired` page), and a distinct color (e.g. `text-orange-500`) to differentiate from + `REJECTED` (red). +6. `documents._index.tsx`: add `[ExtendedDocumentStatus.EXPIRED]: 0` to the `stats` `useState` + initializer and `ExtendedDocumentStatus.EXPIRED` to the tab array. Final order: + `INBOX, PENDING, COMPLETED, DRAFT, REJECTED, EXPIRED, ALL`. +7. (Optional, recommended) `apps/remix/app/components/tables/documents-table-empty-state.tsx`: add + tailored `EXPIRED` and `REJECTED` empty-state copy (currently both fall through to `.otherwise()`). + +### D. Public API boolean `hasExpiredRecipients` (document + envelope, v2) +1. `packages/lib/server-only/document/find-documents.ts`: add `hasExpiredRecipients?: boolean` to + `FindDocumentsOptions`; when true, apply `.where((eb) => hasExpiredRecipient(eb))` inside + `buildBaseQuery` (orthogonal/additive to any `status`). +2. `packages/trpc/server/document-router/find-documents.types.ts`: add a query-safe boolean + `hasExpiredRecipients` to `ZFindDocumentsRequestSchema` with a `.describe(...)`. Mirror the + existing boolean-query-param handling in `find-document-audit-logs.types.ts` + (`filterForRecentActivity`) — avoid raw `z.coerce.boolean()` (the "false" -> true footgun); use a + string transform if needed. Pass it through in `find-documents.ts` (public handler). +3. `packages/lib/server-only/envelope/find-envelopes.ts`: add `hasExpiredRecipients?: boolean` to + `FindEnvelopesOptions` + the `hasExpiredRecipient(eb)` helper + the additive `.where`. +4. `packages/trpc/server/envelope-router/find-envelopes.types.ts`: add the same param to + `ZFindEnvelopesRequestSchema`; pass it through in the envelope-router find handler. + The param auto-appears in the generated `/api/v2/openapi.json`. + +Note: REST v1 `GET /api/v1/documents` is deprecated and lacks status filtering — left unchanged. +`REJECTED` is already a valid public `status` value (`DocumentStatus.REJECTED`), so no API change is +needed for rejected filtering. + +### E. Renew expired links — documentation only +No functional change. Document that resending renews expired links: +- Update the `.description` in `packages/trpc/server/document-router/redistribute-document.types.ts` + and `packages/trpc/server/envelope-router/redistribute-envelope.types.ts` to state that + redistributing refreshes the signing-link expiration for unsigned recipients. +- Optionally adjust resend/redistribute dialog copy + (`apps/remix/app/components/dialogs/document-resend-dialog.tsx`, + `envelope-redistribute-dialog.tsx`) to mention it renews expired links. + +## Files To Modify (summary) + +| Area | File | +|------|------| +| Enum | `packages/prisma/types/extended-document-status.ts` | +| Where-clause + API option | `packages/lib/server-only/document/find-documents.ts` | +| Stats counts | `packages/lib/server-only/document/get-stats.ts` | +| Envelope find (API) | `packages/lib/server-only/envelope/find-envelopes.ts` | +| Internal tRPC stats schema | `packages/trpc/server/document-router/find-documents-internal.types.ts` | +| Public doc API schema + handler | `packages/trpc/server/document-router/find-documents.types.ts`, `find-documents.ts` | +| Public envelope API schema + handler | `packages/trpc/server/envelope-router/find-envelopes.types.ts`, `find-envelopes.ts` | +| Status display | `apps/remix/app/components/general/document/document-status.tsx` | +| Tabs + stats init | `apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx` | +| Empty state (optional) | `apps/remix/app/components/tables/documents-table-empty-state.tsx` | +| Renew docs | `redistribute-document.types.ts`, `redistribute-envelope.types.ts` (+ resend dialogs, optional) | + +## Reused Utilities / Patterns +- `recipientExists` / `senderEmailIs` (per-file Kysely EXISTS helpers) — the template for the new + `hasExpiredRecipient` helper. +- `REJECTED` branches in `find-documents.ts` (lines 279, 416) and `rejectedQuery` in `get-stats.ts` + (line 227) — the template for the `EXPIRED` branches / `expiredQuery`. +- `isRecipientExpired` (`packages/lib/utils/recipients.ts:118`) — defines the `expiresAt <= now` + semantics to match. +- Existing tab machinery in `documents._index.tsx` (`getTabHref`, count badge, personal-org `.filter`) + — works unchanged for the new tabs. +- `resendDocument` / `trpc.document.redistribute` / `trpc.envelope.redistribute` — existing renew path. + +## Verification +1. **Typecheck** (the enum change forces all exhaustive/Record sites): `npm run typecheck -w @documenso/remix`. +2. **Seed + UI** (dev server already running): seed a team via `seedTeam`, send a document, then: + - Reject one as a recipient -> it appears under the new **Rejected** tab with a count. + - Force expiry (set a recipient `expiresAt` in the past, e.g. via Prisma Studio or a short + `envelopeExpirationPeriod`) -> the doc appears under the new **Expired** tab with a count, and the + count excludes signed/CC recipients. +3. **Public API**: `GET /api/v2/document?hasExpiredRecipients=true` and + `GET /api/v2/envelope?hasExpiredRecipients=true` (Bearer API token) return only envelopes with >=1 + expired unsigned recipient; confirm `GET /api/v2/document?status=REJECTED` works. Verify the param + appears in `/api/v2/openapi.json`. +4. **Renew**: on an expired doc, run resend/redistribute (UI dialog or + `POST /api/v2/document/redistribute`) -> recipient `expiresAt` is refreshed, the doc leaves the + Expired tab, and the signing link no longer redirects to `/sign/$token/expired`. +5. **E2E** (optional): extend `packages/app-tests/e2e/envelopes/envelope-expiration-send.spec.ts` + with an Expired-tab assertion. +6. Do **not** modify/commit `packages/lib/translations/*.po`; run `npm run translate` only if needed + for new `msg`/`Trans` strings, and keep generated `.po` files out of the branch. + +## Open Questions +- Exact icon/color for the `EXPIRED` tab (proposed: `TimerOff`, `text-orange-500`). +- Whether to add the optional tailored empty-state copy now or defer. \ No newline at end of file diff --git a/apps/remix/app/components/general/document/document-status.tsx b/apps/remix/app/components/general/document/document-status.tsx index 2f8dbbdf4..933e955af 100644 --- a/apps/remix/app/components/general/document/document-status.tsx +++ b/apps/remix/app/components/general/document/document-status.tsx @@ -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 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`, diff --git a/apps/remix/app/components/tables/documents-table-empty-state.tsx b/apps/remix/app/components/tables/documents-table-empty-state.tsx index 8f8dde45c..918b0f358 100644 --- a/apps/remix/app/components/tables/documents-table-empty-state.tsx +++ b/apps/remix/app/components/tables/documents-table-empty-state.tsx @@ -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.`, diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx index 854283075..3350e29e1 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx @@ -76,6 +76,7 @@ export default function DocumentsPage() { [ExtendedDocumentStatus.COMPLETED]: 0, [ExtendedDocumentStatus.REJECTED]: 0, [ExtendedDocumentStatus.CANCELLED]: 0, + [ExtendedDocumentStatus.EXPIRED]: 0, [ExtendedDocumentStatus.INBOX]: 0, [ExtendedDocumentStatus.ALL]: 0, }); @@ -157,6 +158,8 @@ export default function DocumentsPage() { ExtendedDocumentStatus.COMPLETED, ExtendedDocumentStatus.CANCELLED, ExtendedDocumentStatus.DRAFT, + ExtendedDocumentStatus.REJECTED, + ExtendedDocumentStatus.EXPIRED, ExtendedDocumentStatus.ALL, ] .filter((value) => { diff --git a/packages/app-tests/e2e/api/v2/find-documents.spec.ts b/packages/app-tests/e2e/api/v2/find-documents.spec.ts index e3a4d130e..af916cb7e 100644 --- a/packages/app-tests/e2e/api/v2/find-documents.spec.ts +++ b/packages/app-tests/e2e/api/v2/find-documents.spec.ts @@ -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); + }); +}); diff --git a/packages/app-tests/e2e/api/v2/find-envelopes.spec.ts b/packages/app-tests/e2e/api/v2/find-envelopes.spec.ts index 4b58198be..b3abaef7d 100644 --- a/packages/app-tests/e2e/api/v2/find-envelopes.spec.ts +++ b/packages/app-tests/e2e/api/v2/find-envelopes.spec.ts @@ -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); + }); +}); diff --git a/packages/app-tests/e2e/documents/find-documents.spec.ts b/packages/app-tests/e2e/documents/find-documents.spec.ts index e8eee34e6..960a09863 100644 --- a/packages/app-tests/e2e/documents/find-documents.spec.ts +++ b/packages/app-tests/e2e/documents/find-documents.spec.ts @@ -10,7 +10,14 @@ 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'; @@ -1165,3 +1172,132 @@ test.describe('Find Documents UI - Sender Filter', () => { 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); + }); +}); diff --git a/packages/lib/server-only/admin/get-documents-stats.ts b/packages/lib/server-only/admin/get-documents-stats.ts index 1508f9dd5..15fed8ff7 100644 --- a/packages/lib/server-only/admin/get-documents-stats.ts +++ b/packages/lib/server-only/admin/get-documents-stats.ts @@ -13,7 +13,7 @@ export const getDocumentStats = async () => { }, }); - const stats: Record, number> = { + const stats: Record, number> = { [ExtendedDocumentStatus.DRAFT]: 0, [ExtendedDocumentStatus.PENDING]: 0, [ExtendedDocumentStatus.COMPLETED]: 0, diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index 7a0f67bc7..031ddfb5b 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -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(); }; diff --git a/packages/lib/server-only/document/get-stats.ts b/packages/lib/server-only/document/get-stats.ts index c1af421f7..e3a27534d 100644 --- a/packages/lib/server-only/document/get-stats.ts +++ b/packages/lib/server-only/document/get-stats.ts @@ -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 = { @@ -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, }; diff --git a/packages/lib/server-only/envelope/find-envelopes.ts b/packages/lib/server-only/envelope/find-envelopes.ts index 69bab6939..af37d704b 100644 --- a/packages/lib/server-only/envelope/find-envelopes.ts +++ b/packages/lib/server-only/envelope/find-envelopes.ts @@ -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: diff --git a/packages/lib/server-only/envelope/query-helpers.ts b/packages/lib/server-only/envelope/query-helpers.ts new file mode 100644 index 000000000..57003e9d3 --- /dev/null +++ b/packages/lib/server-only/envelope/query-helpers.ts @@ -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; + +/** + * 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')), + ); diff --git a/packages/prisma/types/extended-document-status.ts b/packages/prisma/types/extended-document-status.ts index 18f01d4bb..ddd9f693e 100644 --- a/packages/prisma/types/extended-document-status.ts +++ b/packages/prisma/types/extended-document-status.ts @@ -4,6 +4,7 @@ export const ExtendedDocumentStatus = { ...DocumentStatus, INBOX: 'INBOX', ALL: 'ALL', + EXPIRED: 'EXPIRED', } as const; export type ExtendedDocumentStatus = (typeof ExtendedDocumentStatus)[keyof typeof ExtendedDocumentStatus]; diff --git a/packages/trpc/server/document-router/find-documents-internal.ts b/packages/trpc/server/document-router/find-documents-internal.ts index ff31b882d..5503e3a0b 100644 --- a/packages/trpc/server/document-router/find-documents-internal.ts +++ b/packages/trpc/server/document-router/find-documents-internal.ts @@ -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, }), ]); diff --git a/packages/trpc/server/document-router/find-documents-internal.types.ts b/packages/trpc/server/document-router/find-documents-internal.types.ts index 453daaf2b..99d903ae5 100644 --- a/packages/trpc/server/document-router/find-documents-internal.types.ts +++ b/packages/trpc/server/document-router/find-documents-internal.types.ts @@ -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(), }), diff --git a/packages/trpc/server/document-router/find-documents.ts b/packages/trpc/server/document-router/find-documents.ts index 475a6b5b6..f82e8b1ff 100644 --- a/packages/trpc/server/document-router/find-documents.ts +++ b/packages/trpc/server/document-router/find-documents.ts @@ -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, diff --git a/packages/trpc/server/document-router/find-documents.types.ts b/packages/trpc/server/document-router/find-documents.types.ts index b41b9456c..da8b40eee 100644 --- a/packages/trpc/server/document-router/find-documents.types.ts +++ b/packages/trpc/server/document-router/find-documents.types.ts @@ -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'), diff --git a/packages/trpc/server/document-router/redistribute-document.types.ts b/packages/trpc/server/document-router/redistribute-document.types.ts index 7fde6c373..05f7efe4d 100644 --- a/packages/trpc/server/document-router/redistribute-document.types.ts +++ b/packages/trpc/server/document-router/redistribute-document.types.ts @@ -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, }, diff --git a/packages/trpc/server/envelope-router/find-envelopes.ts b/packages/trpc/server/envelope-router/find-envelopes.ts index b05faea7e..a845c0723 100644 --- a/packages/trpc/server/envelope-router/find-envelopes.ts +++ b/packages/trpc/server/envelope-router/find-envelopes.ts @@ -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, diff --git a/packages/trpc/server/envelope-router/find-envelopes.types.ts b/packages/trpc/server/envelope-router/find-envelopes.types.ts index a1c4f2ef6..c0a0231a9 100644 --- a/packages/trpc/server/envelope-router/find-envelopes.types.ts +++ b/packages/trpc/server/envelope-router/find-envelopes.types.ts @@ -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'), diff --git a/packages/trpc/server/envelope-router/redistribute-envelope.types.ts b/packages/trpc/server/envelope-router/redistribute-envelope.types.ts index 1926e6bd4..6078ade2a 100644 --- a/packages/trpc/server/envelope-router/redistribute-envelope.types.ts +++ b/packages/trpc/server/envelope-router/redistribute-envelope.types.ts @@ -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'], }, };