feat: rejected and expired recipient filters (#2889)

This commit is contained in:
Ephraim Duncan
2026-07-30 10:41:45 +10:00
committed by GitHub
parent a457e1ef7d
commit 6ec67d1c4d
21 changed files with 867 additions and 10 deletions
@@ -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);
});
});
@@ -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);
});
});
@@ -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);
});
});
@@ -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,
@@ -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();
};
+18 -1
View File
@@ -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<ExtendedDocumentStatus, number> = {
@@ -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,
};
@@ -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:
@@ -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<DB, 'Envelope'>;
/**
* 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')),
);
@@ -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,
@@ -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'),
@@ -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,
},
@@ -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'],
},
};