Compare commits

...

4 Commits

Author SHA1 Message Date
ephraimduncan 6f1c7fed7e chore: merge origin/main into pr-2889 (resolve cancelled/expired status conflicts) 2026-06-25 11:14:29 +00:00
ephraimduncan af70fa1310 docs: add plan for rejected and expired recipient filters 2026-05-29 07:15:30 +00:00
ephraimduncan 12986f5afa fix: forward hasExpiredRecipients in internal documents route
`findDocumentsInternal` inherits `hasExpiredRecipients` from `ZFindDocumentsRequestSchema`
but the handler did not destructure or forward it to `findDocuments`, so callers requesting
the filter received unfiltered results.

Destructure the field from input and pass it through, matching the public `findDocuments`
route.
2026-05-28 23:35:47 +00:00
ephraimduncan e98b37bb10 feat: rejected and expired recipient filters
Add a Rejected tab and an Expired pseudo-status tab to the documents
list, and an orthogonal hasExpiredRecipients boolean to the public v2
document and envelope find APIs.

- Add shared hasExpiredRecipient EXISTS predicate (expiresAt in the
  past, NOT_SIGNED, role != CC) to find-documents, get-stats and
  find-envelopes
- Surface REJECTED in the documents tabs (backend already wired)
- Add EXPIRED extended status: where-clause branches, stats count
  (excluded from ALL since it overlaps PENDING), status display
  (TimerOff/orange), tabs, and empty-state copy
- Expose hasExpiredRecipients on GET /document and /envelope using an
  enum-transform schema to avoid the coerce "false" -> true footgun
- Document that redistributing renews expired signing links
- Add e2e coverage for the expired filter on both endpoints
2026-05-28 23:17:32 +00:00
23 changed files with 490 additions and 13 deletions
@@ -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.
@@ -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`,
@@ -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.`,
@@ -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) => {
@@ -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,123 @@ 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);
});
});
@@ -1055,3 +1055,38 @@ 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);
});
});
@@ -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,
@@ -36,6 +36,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.
@@ -102,6 +107,23 @@ const senderEmailIs = (eb: EnvelopeExpressionBuilder, email: string) =>
.select(sql.lit(1).as('one')),
);
/**
* 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.
* Mirrors `isRecipientExpired` (packages/lib/utils/recipients.ts).
*/
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')),
);
export const findDocuments = async ({
userId,
teamId,
@@ -115,6 +137,7 @@ export const findDocuments = async ({
senderIds,
query = '',
folderId,
hasExpiredRecipients,
useWindowedCount = true,
}: FindDocumentsOptions) => {
const user = await prisma.user.findFirstOrThrow({
@@ -199,6 +222,11 @@ export const findDocuments = async ({
);
}
// Expired recipient filter (orthogonal to status, additive)
if (hasExpiredRecipients) {
qb = qb.where((eb) => hasExpiredRecipient(eb));
}
return qb;
};
@@ -305,6 +333,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 +492,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();
};
+34 -1
View File
@@ -51,6 +51,23 @@ const senderEmailIs = (eb: EnvelopeExpressionBuilder, email: string) =>
.select(sql.lit(1).as('one')),
);
/**
* 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.
* Mirrors `isRecipientExpired` (packages/lib/utils/recipients.ts).
*/
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')),
);
export type GetStatsInput = {
userId: number;
teamId: number;
@@ -253,6 +270,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 +304,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 +323,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,
};
@@ -37,9 +37,9 @@ import { extractDerivedDocumentMeta } from '../../utils/document';
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
import { buildTeamWhereQuery } from '../../utils/teams';
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
import { getTeamSettings } from '../team/get-team-settings';
import { assertUserNotDisabledById } from '../user/assert-user-not-disabled';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -10,8 +10,8 @@ import { nanoid, prefixedId } from '../../universal/id';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export interface DuplicateEnvelopeOptions {
@@ -1,6 +1,7 @@
import { kyselyPrisma, prisma, sql } from '@documenso/prisma';
import type { DB } from '@documenso/prisma/generated/types';
import type { DocumentSource, DocumentStatus, Envelope, EnvelopeType } from '@prisma/client';
import { RecipientRole, SigningStatus } from '@prisma/client';
import type { Expression, ExpressionBuilder, SelectQueryBuilder, SqlBool } from 'kysely';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
@@ -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.
@@ -87,6 +93,23 @@ const senderEmailIs = (eb: EnvelopeExpressionBuilder, email: string) =>
.select(sql.lit(1).as('one')),
);
/**
* 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.
* Mirrors `isRecipientExpired` (packages/lib/utils/recipients.ts).
*/
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')),
);
/**
* Find envelopes visible to the requesting user within a team.
*
@@ -106,6 +129,7 @@ export const findEnvelopes = async ({
orderBy,
query = '',
folderId,
hasExpiredRecipients,
useWindowedCount = true,
}: FindEnvelopesOptions) => {
const user = await prisma.user.findFirstOrThrow({
@@ -182,6 +206,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:
@@ -51,8 +51,8 @@ import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { incrementDocumentId } from '../envelope/increment-id';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { getOrganisationTemplateWhereInput } from './get-organisation-template-by-id';
+1 -1
View File
@@ -52,7 +52,7 @@ export const ZClaimFlagsSchema = z.object({
signingReminders: z.boolean().optional(),
cscQesSigning: z.boolean().optional(),
/**
* Controls whether an organisation is prevented from sending emails.
*
@@ -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,
@@ -19,6 +19,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:
'Redistribute the document to the provided recipients who have not actioned the document. Will use the distribution method set in the document',
'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'],
},
};
@@ -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'],
},
};