Files
documenso/.agents/plans/wild-indigo-wave-rejected-expired-recipient-filters.md
T

9.8 KiB

date, title
date title
2026-05-28 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_MAPlabel: 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.

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.