mirror of
https://github.com/documenso/documenso.git
synced 2026-07-11 05:25:08 +10:00
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:
@@ -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<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 } 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.`,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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<ExtendedDocumentStatus, number> = {
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -4,6 +4,7 @@ export const ExtendedDocumentStatus = {
|
||||
...DocumentStatus,
|
||||
INBOX: 'INBOX',
|
||||
ALL: 'ALL',
|
||||
EXPIRED: 'EXPIRED',
|
||||
} as const;
|
||||
|
||||
export type ExtendedDocumentStatus = (typeof ExtendedDocumentStatus)[keyof typeof ExtendedDocumentStatus];
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user