feat: expiry links

This commit is contained in:
Ephraim Atta-Duncan
2025-08-18 14:22:43 +00:00
parent ea7a2c2712
commit e24d00e23e
32 changed files with 935 additions and 6 deletions

View File

@ -15,6 +15,7 @@ export const getRecipientsStats = async () => {
[SigningStatus.SIGNED]: 0,
[SigningStatus.NOT_SIGNED]: 0,
[SigningStatus.REJECTED]: 0,
[SigningStatus.EXPIRED]: 0,
[SendStatus.SENT]: 0,
[SendStatus.NOT_SENT]: 0,
};

View File

@ -6,6 +6,7 @@ import {
createDocumentAuditLogData,
diffDocumentMetaChanges,
} from '@documenso/lib/utils/document-audit-logs';
import { calculateRecipientExpiry } from '@documenso/lib/utils/expiry';
import { prisma } from '@documenso/prisma';
import type { SupportedLanguageCodes } from '../../constants/i18n';
@ -33,6 +34,8 @@ export type CreateDocumentMetaOptions = {
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
language?: SupportedLanguageCodes;
expiryAmount?: number;
expiryUnit?: string;
requestMetadata: ApiRequestMetadata;
};
@ -56,6 +59,8 @@ export const upsertDocumentMeta = async ({
uploadSignatureEnabled,
drawSignatureEnabled,
language,
expiryAmount,
expiryUnit,
requestMetadata,
}: CreateDocumentMetaOptions) => {
const { documentWhereInput, team } = await getDocumentWhereInput({
@ -118,6 +123,8 @@ export const upsertDocumentMeta = async ({
uploadSignatureEnabled,
drawSignatureEnabled,
language,
expiryAmount,
expiryUnit,
},
update: {
subject,
@ -136,9 +143,30 @@ export const upsertDocumentMeta = async ({
uploadSignatureEnabled,
drawSignatureEnabled,
language,
expiryAmount,
expiryUnit,
},
});
if (expiryAmount !== undefined || expiryUnit !== undefined) {
const newExpiryDate = calculateRecipientExpiry(
upsertedDocumentMeta.expiryAmount,
upsertedDocumentMeta.expiryUnit,
new Date(),
);
await tx.recipient.updateMany({
where: {
documentId,
signingStatus: { not: 'SIGNED' },
role: { not: 'CC' },
},
data: {
expired: newExpiryDate,
},
});
}
const changes = diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta);
if (changes.length > 0) {

View File

@ -27,6 +27,7 @@ import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { extractDerivedDocumentMeta } from '../../utils/document';
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
import { determineDocumentVisibility } from '../../utils/document-visibility';
import { calculateRecipientExpiry } from '../../utils/expiry';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getMemberRoles } from '../team/get-member-roles';
import { getTeamSettings } from '../team/get-team-settings';
@ -45,6 +46,8 @@ export type CreateDocumentOptions = {
globalActionAuth?: TDocumentActionAuthTypes[];
formValues?: TDocumentFormValues;
recipients: TCreateDocumentV2Request['recipients'];
expiryAmount?: number;
expiryUnit?: string;
};
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
requestMetadata: ApiRequestMetadata;
@ -167,7 +170,11 @@ export const createDocumentV2 = async ({
formValues,
source: DocumentSource.DOCUMENT,
documentMeta: {
create: extractDerivedDocumentMeta(settings, meta),
create: extractDerivedDocumentMeta(settings, {
...meta,
expiryAmount: data.expiryAmount,
expiryUnit: data.expiryUnit,
}),
},
},
});
@ -179,6 +186,12 @@ export const createDocumentV2 = async ({
actionAuth: recipient.actionAuth ?? [],
});
const expiryDate = calculateRecipientExpiry(
data.expiryAmount ?? null,
data.expiryUnit ?? null,
new Date(), // Calculate from current time
);
await tx.recipient.create({
data: {
documentId: document.id,
@ -191,6 +204,7 @@ export const createDocumentV2 = async ({
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
authOptions: recipientAuthOptions,
expired: expiryDate,
fields: {
createMany: {
data: (recipient.fields || []).map((field) => ({

View File

@ -34,6 +34,8 @@ export type CreateDocumentOptions = {
userTimezone?: string;
requestMetadata: ApiRequestMetadata;
folderId?: string;
expiryAmount?: number;
expiryUnit?: string;
};
export const createDocument = async ({
@ -48,6 +50,8 @@ export const createDocument = async ({
timezone,
userTimezone,
folderId,
expiryAmount,
expiryUnit,
}: CreateDocumentOptions) => {
const team = await getTeamById({ userId, teamId });
@ -126,6 +130,8 @@ export const createDocument = async ({
documentMeta: {
create: extractDerivedDocumentMeta(settings, {
timezone: timezoneToUse,
expiryAmount,
expiryUnit,
}),
},
},

View File

@ -19,6 +19,7 @@ import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { isDocumentCompleted } from '../../utils/document';
import { calculateRecipientExpiry } from '../../utils/expiry';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
import { getDocumentWhereInput } from './get-document-by-id';
@ -199,6 +200,23 @@ export const resendDocument = async ({
text,
});
if (document.documentMeta?.expiryAmount && document.documentMeta?.expiryUnit) {
const newExpiryDate = calculateRecipientExpiry(
document.documentMeta.expiryAmount,
document.documentMeta.expiryUnit,
new Date(),
);
await tx.recipient.update({
where: {
id: recipient.id,
},
data: {
expired: newExpiryDate,
},
});
}
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,

View File

@ -21,6 +21,7 @@ import {
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { isDocumentCompleted } from '../../utils/document';
import { calculateRecipientExpiry } from '../../utils/expiry';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { getDocumentWhereInput } from './get-document-by-id';
@ -213,6 +214,24 @@ export const sendDocument = async ({
});
}
if (document.documentMeta?.expiryAmount && document.documentMeta?.expiryUnit) {
const expiryDate = calculateRecipientExpiry(
document.documentMeta.expiryAmount,
document.documentMeta.expiryUnit,
new Date(), // Calculate from current time
);
await tx.recipient.updateMany({
where: {
documentId: document.id,
expired: null,
},
data: {
expired: expiryDate,
},
});
}
return await tx.document.update({
where: {
id: documentId,

View File

@ -25,7 +25,9 @@ import {
} from '../../types/field-meta';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { isRecipientExpired } from '../../utils/expiry';
import { validateFieldAuth } from '../document/validate-field-auth';
import { expireRecipient } from '../recipient/expire-recipient';
export type SignFieldWithTokenOptions = {
token: string;
@ -115,6 +117,11 @@ export const signFieldWithToken = async ({
throw new Error(`Recipient ${recipient.id} has already signed`);
}
if (isRecipientExpired(recipient)) {
await expireRecipient({ recipientId: recipient.id });
throw new Error(`Signing link has expired`);
}
if (field.inserted) {
throw new Error(`Field ${fieldId} has already been inserted`);
}

View File

@ -0,0 +1,36 @@
import { SigningStatus } from '@prisma/client';
import { prisma } from '@documenso/prisma';
export type ExpireRecipientOptions = {
recipientId: number;
};
export const expireRecipient = async ({ recipientId }: ExpireRecipientOptions) => {
const recipient = await prisma.recipient.findFirst({
where: {
id: recipientId,
},
select: {
id: true,
signingStatus: true,
},
});
if (!recipient) {
return null;
}
if (recipient.signingStatus === SigningStatus.EXPIRED) {
return recipient;
}
return await prisma.recipient.update({
where: {
id: recipientId,
},
data: {
signingStatus: SigningStatus.EXPIRED,
},
});
};

View File

@ -46,6 +46,7 @@ import {
createRecipientAuthOptions,
extractDocumentAuthMethods,
} from '../../utils/document-auth';
import { calculateRecipientExpiry } from '../../utils/expiry';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@ -91,6 +92,8 @@ export type CreateDocumentFromTemplateOptions = {
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
expiryAmount?: number;
expiryUnit?: string;
};
requestMetadata: ApiRequestMetadata;
};
@ -399,6 +402,9 @@ export const createDocumentFromTemplate = async ({
override?.drawSignatureEnabled ?? template.templateMeta?.drawSignatureEnabled,
allowDictateNextSigner:
override?.allowDictateNextSigner ?? template.templateMeta?.allowDictateNextSigner,
defaultExpiryAmount:
override?.expiryAmount ?? template.templateMeta?.defaultExpiryAmount,
defaultExpiryUnit: override?.expiryUnit ?? template.templateMeta?.defaultExpiryUnit,
}),
},
recipients: {
@ -406,6 +412,17 @@ export const createDocumentFromTemplate = async ({
data: finalRecipients.map((recipient) => {
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient?.authOptions);
// Calculate expiry date based on template defaults
const expiryAmount =
override?.expiryAmount ?? template.templateMeta?.defaultExpiryAmount ?? null;
const expiryUnit =
override?.expiryUnit ?? template.templateMeta?.defaultExpiryUnit ?? null;
const recipientExpiryDate = calculateRecipientExpiry(
expiryAmount,
expiryUnit,
new Date(), // Calculate from current time
);
return {
email: recipient.email,
name: recipient.name,
@ -421,6 +438,7 @@ export const createDocumentFromTemplate = async ({
? SigningStatus.SIGNED
: SigningStatus.NOT_SIGNED,
signingOrder: recipient.signingOrder,
expired: recipientExpiryDate,
token: nanoid(),
};
}),