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:
REJECTEDis already fully wired in the backend — the where-clause (find-documents.ts), stats counts (get-stats.ts), tRPC response schema,ExtendedDocumentStatusenum, and theFRIENDLY_STATUS_MAPdisplay all handle it. It is simply absent from the UI tab array.- Renewing expired links already works.
resendDocumentrefreshesexpiresAtand clearsexpirationNotifiedAtfor unsigned, non-CC recipients (resend-document.ts:98-121), exposed publicly viaPOST /api/v2/document/redistributeand/api/v2/envelope/redistributeand 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: addExtendedDocumentStatus.REJECTEDto the tab array (lines 149-155). Count badge, highlight, and?status=REJECTEDfiltering already work via existing machinery.
C. EXPIRED pseudo-status (UI + internal stats)
packages/prisma/types/extended-document-status.ts: addEXPIRED: 'EXPIRED'. Internal-only — the publicDocumentStatusenum is unaffected. This intentionally surfaces TS errors at the three exhaustive/Record<ExtendedDocumentStatus>sites below, forcing them to be handled.packages/lib/server-only/document/find-documents.ts:- Add
.with(ExtendedDocumentStatus.EXPIRED, ...)to bothapplyPersonalFiltersandapplyTeamFilters, mirroring theCOMPLETEDbranch's access control (deleted + visibility + owner/recipient access) withhasExpiredRecipient(eb)AND-ed in. Do not constrainEnvelope.status— the EXISTS already restricts to unsigned recipients.
- Add
packages/lib/server-only/document/get-stats.ts:- Add an
expiredQuerymirroringpendingQuery's access control +hasExpiredRecipient(eb). - Add it to the
Promise.all, add[ExtendedDocumentStatus.EXPIRED]: expiredto thestatsrecord. Do not addexpiredto theallsum (it overlapsPENDING).
- Add an
packages/trpc/server/document-router/find-documents-internal.types.ts: add[ExtendedDocumentStatus.EXPIRED]: z.number()to thestatsresponse object. (statusalready accepts the extended enum viaz.nativeEnum(ExtendedDocumentStatus).)apps/remix/app/components/general/document/document-status.tsx: add anEXPIREDentry toFRIENDLY_STATUS_MAP—label: msgExpired, an icon (e.g. lucideTimerOff, matching the/sign/$token/expiredpage), and a distinct color (e.g.text-orange-500) to differentiate fromREJECTED(red).documents._index.tsx: add[ExtendedDocumentStatus.EXPIRED]: 0to thestatsuseStateinitializer andExtendedDocumentStatus.EXPIREDto the tab array. Final order:INBOX, PENDING, COMPLETED, DRAFT, REJECTED, EXPIRED, ALL.- (Optional, recommended)
apps/remix/app/components/tables/documents-table-empty-state.tsx: add tailoredEXPIREDandREJECTEDempty-state copy (currently both fall through to.otherwise()).
D. Public API boolean hasExpiredRecipients (document + envelope, v2)
packages/lib/server-only/document/find-documents.ts: addhasExpiredRecipients?: booleantoFindDocumentsOptions; when true, apply.where((eb) => hasExpiredRecipient(eb))insidebuildBaseQuery(orthogonal/additive to anystatus).packages/trpc/server/document-router/find-documents.types.ts: add a query-safe booleanhasExpiredRecipientstoZFindDocumentsRequestSchemawith a.describe(...). Mirror the existing boolean-query-param handling infind-document-audit-logs.types.ts(filterForRecentActivity) — avoid rawz.coerce.boolean()(the "false" -> true footgun); use a string transform if needed. Pass it through infind-documents.ts(public handler).packages/lib/server-only/envelope/find-envelopes.ts: addhasExpiredRecipients?: booleantoFindEnvelopesOptions+ thehasExpiredRecipient(eb)helper + the additive.where.packages/trpc/server/envelope-router/find-envelopes.types.ts: add the same param toZFindEnvelopesRequestSchema; 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
.descriptioninpackages/trpc/server/document-router/redistribute-document.types.tsandpackages/trpc/server/envelope-router/redistribute-envelope.types.tsto 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 newhasExpiredRecipienthelper.REJECTEDbranches infind-documents.ts(lines 279, 416) andrejectedQueryinget-stats.ts(line 227) — the template for theEXPIREDbranches /expiredQuery.isRecipientExpired(packages/lib/utils/recipients.ts:118) — defines theexpiresAt <= nowsemantics 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
- Typecheck (the enum change forces all exhaustive/Record sites):
npm run typecheck -w @documenso/remix. - 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
expiresAtin the past, e.g. via Prisma Studio or a shortenvelopeExpirationPeriod) -> the doc appears under the new Expired tab with a count, and the count excludes signed/CC recipients.
- Public API:
GET /api/v2/document?hasExpiredRecipients=trueandGET /api/v2/envelope?hasExpiredRecipients=true(Bearer API token) return only envelopes with >=1 expired unsigned recipient; confirmGET /api/v2/document?status=REJECTEDworks. Verify the param appears in/api/v2/openapi.json. - Renew: on an expired doc, run resend/redistribute (UI dialog or
POST /api/v2/document/redistribute) -> recipientexpiresAtis refreshed, the doc leaves the Expired tab, and the signing link no longer redirects to/sign/$token/expired. - E2E (optional): extend
packages/app-tests/e2e/envelopes/envelope-expiration-send.spec.tswith an Expired-tab assertion. - Do not modify/commit
packages/lib/translations/*.po; runnpm run translateonly if needed for newmsg/Transstrings, and keep generated.pofiles out of the branch.
Open Questions
- Exact icon/color for the
EXPIREDtab (proposed:TimerOff,text-orange-500). - Whether to add the optional tailored empty-state copy now or defer.