From e98b37bb1015a2d396f4697e8b54733eab2d6529 Mon Sep 17 00:00:00 2001 From: ephraimduncan Date: Thu, 28 May 2026 23:17:32 +0000 Subject: [PATCH] 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 --- .../general/document/document-status.tsx | 8 +- .../tables/documents-table-empty-state.tsx | 12 +- .../t.$teamUrl+/documents._index.tsx | 3 + .../e2e/api/v2/find-documents.spec.ts | 128 +++++++++++++++++- .../e2e/api/v2/find-envelopes.spec.ts | 35 +++++ .../server-only/admin/get-documents-stats.ts | 2 +- .../server-only/document/find-documents.ts | 49 +++++++ .../lib/server-only/document/get-stats.ts | 35 ++++- .../server-only/envelope/find-envelopes.ts | 29 ++++ .../prisma/types/extended-document-status.ts | 1 + .../find-documents-internal.types.ts | 1 + .../server/document-router/find-documents.ts | 14 +- .../document-router/find-documents.types.ts | 5 + .../redistribute-document.types.ts | 2 +- .../server/envelope-router/find-envelopes.ts | 16 ++- .../envelope-router/find-envelopes.types.ts | 5 + .../redistribute-envelope.types.ts | 2 +- 17 files changed, 338 insertions(+), 9 deletions(-) diff --git a/apps/remix/app/components/general/document/document-status.tsx b/apps/remix/app/components/general/document/document-status.tsx index e17fc12d5..75a3bdf1c 100644 --- a/apps/remix/app/components/general/document/document-status.tsx +++ b/apps/remix/app/components/general/document/document-status.tsx @@ -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'; @@ -40,6 +40,12 @@ export const FRIENDLY_STATUS_MAP: Record 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`, diff --git a/apps/remix/app/components/tables/documents-table-empty-state.tsx b/apps/remix/app/components/tables/documents-table-empty-state.tsx index 1b049cdac..86d3b4a3e 100644 --- a/apps/remix/app/components/tables/documents-table-empty-state.tsx +++ b/apps/remix/app/components/tables/documents-table-empty-state.tsx @@ -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 } from 'lucide-react'; +import { Bird, CheckCircle2, TimerOff, XCircle } from 'lucide-react'; import { match } from 'ts-pattern'; export type DocumentsTableEmptyStateProps = { status: ExtendedDocumentStatus }; @@ -24,6 +24,16 @@ export const DocumentsTableEmptyState = ({ status }: DocumentsTableEmptyStatePro message: msg`There are no active drafts at the current moment. You can upload a document to start drafting.`, icon: CheckCircle2, })) + .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.`, diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx index 864f9ab04..e0ae3b92b 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx @@ -71,6 +71,7 @@ export default function DocumentsPage() { [ExtendedDocumentStatus.PENDING]: 0, [ExtendedDocumentStatus.COMPLETED]: 0, [ExtendedDocumentStatus.REJECTED]: 0, + [ExtendedDocumentStatus.EXPIRED]: 0, [ExtendedDocumentStatus.INBOX]: 0, [ExtendedDocumentStatus.ALL]: 0, }); @@ -151,6 +152,8 @@ export default function DocumentsPage() { ExtendedDocumentStatus.PENDING, ExtendedDocumentStatus.COMPLETED, ExtendedDocumentStatus.DRAFT, + ExtendedDocumentStatus.REJECTED, + ExtendedDocumentStatus.EXPIRED, ExtendedDocumentStatus.ALL, ] .filter((value) => { diff --git a/packages/app-tests/e2e/api/v2/find-documents.spec.ts b/packages/app-tests/e2e/api/v2/find-documents.spec.ts index e3a4d130e..ed485e45d 100644 --- a/packages/app-tests/e2e/api/v2/find-documents.spec.ts +++ b/packages/app-tests/e2e/api/v2/find-documents.spec.ts @@ -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); + }); +}); diff --git a/packages/app-tests/e2e/api/v2/find-envelopes.spec.ts b/packages/app-tests/e2e/api/v2/find-envelopes.spec.ts index 4b58198be..c6626a451 100644 --- a/packages/app-tests/e2e/api/v2/find-envelopes.spec.ts +++ b/packages/app-tests/e2e/api/v2/find-envelopes.spec.ts @@ -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); + }); +}); diff --git a/packages/lib/server-only/admin/get-documents-stats.ts b/packages/lib/server-only/admin/get-documents-stats.ts index 551744f7c..7477d779c 100644 --- a/packages/lib/server-only/admin/get-documents-stats.ts +++ b/packages/lib/server-only/admin/get-documents-stats.ts @@ -13,7 +13,7 @@ export const getDocumentStats = async () => { }, }); - const stats: Record, number> = { + const stats: Record, number> = { [ExtendedDocumentStatus.DRAFT]: 0, [ExtendedDocumentStatus.PENDING]: 0, [ExtendedDocumentStatus.COMPLETED]: 0, diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index 60588c766..2a447474e 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -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; }; @@ -291,6 +319,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(); }; @@ -429,6 +466,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(); }; diff --git a/packages/lib/server-only/document/get-stats.ts b/packages/lib/server-only/document/get-stats.ts index 609e594df..5a83ff2ad 100644 --- a/packages/lib/server-only/document/get-stats.ts +++ b/packages/lib/server-only/document/get-stats.ts @@ -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; @@ -239,6 +256,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 @@ -260,14 +290,16 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId, // ─── Execute all counts in parallel ────────────────────────────────── - const [draft, pending, completed, rejected, inbox] = await Promise.all([ + const [draft, pending, completed, rejected, expired, inbox] = await Promise.all([ cappedCount(draftQuery), cappedCount(pendingQuery), cappedCount(completedQuery), cappedCount(rejectedQuery), + 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 + inbox, STATS_COUNT_CAP); const stats: Record = { @@ -275,6 +307,7 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId, [ExtendedDocumentStatus.PENDING]: pending, [ExtendedDocumentStatus.COMPLETED]: completed, [ExtendedDocumentStatus.REJECTED]: rejected, + [ExtendedDocumentStatus.EXPIRED]: expired, [ExtendedDocumentStatus.INBOX]: inbox, [ExtendedDocumentStatus.ALL]: all, }; diff --git a/packages/lib/server-only/envelope/find-envelopes.ts b/packages/lib/server-only/envelope/find-envelopes.ts index 69bab6939..a9534b568 100644 --- a/packages/lib/server-only/envelope/find-envelopes.ts +++ b/packages/lib/server-only/envelope/find-envelopes.ts @@ -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: diff --git a/packages/prisma/types/extended-document-status.ts b/packages/prisma/types/extended-document-status.ts index 18f01d4bb..ddd9f693e 100644 --- a/packages/prisma/types/extended-document-status.ts +++ b/packages/prisma/types/extended-document-status.ts @@ -4,6 +4,7 @@ export const ExtendedDocumentStatus = { ...DocumentStatus, INBOX: 'INBOX', ALL: 'ALL', + EXPIRED: 'EXPIRED', } as const; export type ExtendedDocumentStatus = (typeof ExtendedDocumentStatus)[keyof typeof ExtendedDocumentStatus]; diff --git a/packages/trpc/server/document-router/find-documents-internal.types.ts b/packages/trpc/server/document-router/find-documents-internal.types.ts index f6347cfc3..139f0e24c 100644 --- a/packages/trpc/server/document-router/find-documents-internal.types.ts +++ b/packages/trpc/server/document-router/find-documents-internal.types.ts @@ -19,6 +19,7 @@ export const ZFindDocumentsInternalResponseSchema = ZFindResultResponse.extend({ [ExtendedDocumentStatus.PENDING]: z.number(), [ExtendedDocumentStatus.COMPLETED]: z.number(), [ExtendedDocumentStatus.REJECTED]: z.number(), + [ExtendedDocumentStatus.EXPIRED]: z.number(), [ExtendedDocumentStatus.INBOX]: z.number(), [ExtendedDocumentStatus.ALL]: z.number(), }), diff --git a/packages/trpc/server/document-router/find-documents.ts b/packages/trpc/server/document-router/find-documents.ts index 475a6b5b6..f82e8b1ff 100644 --- a/packages/trpc/server/document-router/find-documents.ts +++ b/packages/trpc/server/document-router/find-documents.ts @@ -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, diff --git a/packages/trpc/server/document-router/find-documents.types.ts b/packages/trpc/server/document-router/find-documents.types.ts index 81ee4e3bf..f169417c4 100644 --- a/packages/trpc/server/document-router/find-documents.types.ts +++ b/packages/trpc/server/document-router/find-documents.types.ts @@ -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'), diff --git a/packages/trpc/server/document-router/redistribute-document.types.ts b/packages/trpc/server/document-router/redistribute-document.types.ts index d81b4204e..cf7cf038d 100644 --- a/packages/trpc/server/document-router/redistribute-document.types.ts +++ b/packages/trpc/server/document-router/redistribute-document.types.ts @@ -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'], }, }; diff --git a/packages/trpc/server/envelope-router/find-envelopes.ts b/packages/trpc/server/envelope-router/find-envelopes.ts index b05faea7e..a845c0723 100644 --- a/packages/trpc/server/envelope-router/find-envelopes.ts +++ b/packages/trpc/server/envelope-router/find-envelopes.ts @@ -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, diff --git a/packages/trpc/server/envelope-router/find-envelopes.types.ts b/packages/trpc/server/envelope-router/find-envelopes.types.ts index a1c4f2ef6..c0a0231a9 100644 --- a/packages/trpc/server/envelope-router/find-envelopes.types.ts +++ b/packages/trpc/server/envelope-router/find-envelopes.types.ts @@ -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'), diff --git a/packages/trpc/server/envelope-router/redistribute-envelope.types.ts b/packages/trpc/server/envelope-router/redistribute-envelope.types.ts index 1926e6bd4..6078ade2a 100644 --- a/packages/trpc/server/envelope-router/redistribute-envelope.types.ts +++ b/packages/trpc/server/envelope-router/redistribute-envelope.types.ts @@ -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'], }, };