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
This commit is contained in:
ephraimduncan
2026-05-28 23:17:32 +00:00
parent 22ceff43e3
commit e98b37bb10
17 changed files with 338 additions and 9 deletions
@@ -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: