diff --git a/apps/remix/app/components/general/document/document-edit-form.tsx b/apps/remix/app/components/general/document/document-edit-form.tsx index e03744164..7351fad4b 100644 --- a/apps/remix/app/components/general/document/document-edit-form.tsx +++ b/apps/remix/app/components/general/document/document-edit-form.tsx @@ -198,6 +198,8 @@ export const DocumentEditForm = ({ typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE), uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD), drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW), + expiryAmount: data.meta.expiryAmount, + expiryUnit: data.meta.expiryUnit, }, }); diff --git a/apps/remix/app/components/general/document/document-page-view-recipients.tsx b/apps/remix/app/components/general/document/document-page-view-recipients.tsx index 2e413a8ad..88561f5f4 100644 --- a/apps/remix/app/components/general/document/document-page-view-recipients.tsx +++ b/apps/remix/app/components/general/document/document-page-view-recipients.tsx @@ -158,6 +158,14 @@ export const DocumentPageViewRecipients = ({ )} + {document.status !== DocumentStatus.DRAFT && + recipient.signingStatus === SigningStatus.EXPIRED && ( + + + Expired + + )} + {document.status === DocumentStatus.PENDING && recipient.signingStatus === SigningStatus.NOT_SIGNED && recipient.role !== RecipientRole.CC && ( diff --git a/apps/remix/app/components/general/stack-avatar.tsx b/apps/remix/app/components/general/stack-avatar.tsx index beafbebd5..f46968f7b 100644 --- a/apps/remix/app/components/general/stack-avatar.tsx +++ b/apps/remix/app/components/general/stack-avatar.tsx @@ -41,6 +41,9 @@ export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAva case RecipientStatusType.REJECTED: classes = 'bg-red-200 text-red-800'; break; + case RecipientStatusType.EXPIRED: + classes = 'bg-orange-200 text-orange-800'; + break; default: break; } diff --git a/apps/remix/app/components/general/stack-avatars-with-tooltip.tsx b/apps/remix/app/components/general/stack-avatars-with-tooltip.tsx index dd1659c3f..2457b1e22 100644 --- a/apps/remix/app/components/general/stack-avatars-with-tooltip.tsx +++ b/apps/remix/app/components/general/stack-avatars-with-tooltip.tsx @@ -48,13 +48,20 @@ export const StackAvatarsWithTooltip = ({ (recipient) => getRecipientType(recipient) === RecipientStatusType.REJECTED, ); + const expiredRecipients = recipients.filter( + (recipient) => getRecipientType(recipient) === RecipientStatusType.EXPIRED, + ); + const sortedRecipients = useMemo(() => { const otherRecipients = recipients.filter( - (recipient) => getRecipientType(recipient) !== RecipientStatusType.REJECTED, + (recipient) => + getRecipientType(recipient) !== RecipientStatusType.REJECTED && + getRecipientType(recipient) !== RecipientStatusType.EXPIRED, ); return [ ...rejectedRecipients.sort((a, b) => a.id - b.id), + ...expiredRecipients.sort((a, b) => a.id - b.id), ...otherRecipients.sort((a, b) => { return a.id - b.id; }), @@ -117,6 +124,30 @@ export const StackAvatarsWithTooltip = ({ )} + {expiredRecipients.length > 0 && ( +
+

+ Expired +

+ {expiredRecipients.map((recipient: Recipient) => ( +
+ +
+

{recipient.email}

+

+ {_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)} +

+
+
+ ))} +
+ )} + {waitingRecipients.length > 0 && (

diff --git a/apps/remix/app/components/tables/documents-table-action-button.tsx b/apps/remix/app/components/tables/documents-table-action-button.tsx index 1333ca912..ef1323479 100644 --- a/apps/remix/app/components/tables/documents-table-action-button.tsx +++ b/apps/remix/app/components/tables/documents-table-action-button.tsx @@ -2,7 +2,7 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client'; -import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react'; +import { CheckCircle, Clock, Download, Edit, EyeIcon, Pencil } from 'lucide-react'; import { Link } from 'react-router'; import { match } from 'ts-pattern'; @@ -36,6 +36,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr const isPending = row.status === DocumentStatus.PENDING; const isComplete = isDocumentCompleted(row.status); const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; + const isExpired = recipient?.signingStatus === SigningStatus.EXPIRED; const role = recipient?.role; const isCurrentTeamDocument = team && row.team?.url === team.url; @@ -87,8 +88,15 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr isPending, isComplete, isSigned, + isExpired, isCurrentTeamDocument, }) + .with({ isRecipient: true, isExpired: true }, () => ( + + )) .with( isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true }, () => ( diff --git a/apps/remix/app/components/tables/inbox-table.tsx b/apps/remix/app/components/tables/inbox-table.tsx index 45f837c17..d63067e70 100644 --- a/apps/remix/app/components/tables/inbox-table.tsx +++ b/apps/remix/app/components/tables/inbox-table.tsx @@ -5,7 +5,7 @@ import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { DocumentStatus as DocumentStatusEnum } from '@prisma/client'; import { RecipientRole, SigningStatus } from '@prisma/client'; -import { CheckCircleIcon, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react'; +import { CheckCircleIcon, Clock, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react'; import { DateTime } from 'luxon'; import { Link, useSearchParams } from 'react-router'; import { match } from 'ts-pattern'; @@ -194,6 +194,7 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) => const isPending = row.status === DocumentStatusEnum.PENDING; const isComplete = isDocumentCompleted(row.status); const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; + const isExpired = recipient?.signingStatus === SigningStatus.EXPIRED; const role = recipient?.role; if (!recipient) { @@ -231,7 +232,14 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) => isPending, isComplete, isSigned, + isExpired, }) + .with({ isExpired: true }, () => ( + + )) .with({ isPending: true, isSigned: false }, () => ( + )} +

+ + ); +} diff --git a/packages/lib/client-only/recipient-type.ts b/packages/lib/client-only/recipient-type.ts index 1701fc1be..e2498f826 100644 --- a/packages/lib/client-only/recipient-type.ts +++ b/packages/lib/client-only/recipient-type.ts @@ -13,6 +13,7 @@ export enum RecipientStatusType { WAITING = 'waiting', UNSIGNED = 'unsigned', REJECTED = 'rejected', + EXPIRED = 'expired', } export const getRecipientType = ( @@ -27,6 +28,10 @@ export const getRecipientType = ( return RecipientStatusType.REJECTED; } + if (recipient.signingStatus === SigningStatus.EXPIRED) { + return RecipientStatusType.EXPIRED; + } + if ( recipient.readStatus === ReadStatus.OPENED && recipient.signingStatus === SigningStatus.NOT_SIGNED @@ -52,6 +57,10 @@ export const getExtraRecipientsType = (extraRecipients: Recipient[]) => { return RecipientStatusType.UNSIGNED; } + if (types.includes(RecipientStatusType.EXPIRED)) { + return RecipientStatusType.EXPIRED; + } + if (types.includes(RecipientStatusType.OPENED)) { return RecipientStatusType.OPENED; } diff --git a/packages/lib/server-only/admin/get-recipients-stats.ts b/packages/lib/server-only/admin/get-recipients-stats.ts index 226d5112a..3ed8581cf 100644 --- a/packages/lib/server-only/admin/get-recipients-stats.ts +++ b/packages/lib/server-only/admin/get-recipients-stats.ts @@ -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, }; diff --git a/packages/lib/server-only/document-meta/upsert-document-meta.ts b/packages/lib/server-only/document-meta/upsert-document-meta.ts index 08ceadcba..0a809ec2e 100644 --- a/packages/lib/server-only/document-meta/upsert-document-meta.ts +++ b/packages/lib/server-only/document-meta/upsert-document-meta.ts @@ -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) { diff --git a/packages/lib/server-only/document/create-document-v2.ts b/packages/lib/server-only/document/create-document-v2.ts index a381fd238..8e9cebbe0 100644 --- a/packages/lib/server-only/document/create-document-v2.ts +++ b/packages/lib/server-only/document/create-document-v2.ts @@ -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>; 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) => ({ diff --git a/packages/lib/server-only/document/create-document.ts b/packages/lib/server-only/document/create-document.ts index 1f1bab522..d9e2c1550 100644 --- a/packages/lib/server-only/document/create-document.ts +++ b/packages/lib/server-only/document/create-document.ts @@ -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, }), }, }, diff --git a/packages/lib/server-only/document/resend-document.ts b/packages/lib/server-only/document/resend-document.ts index 66e6fb09f..b63c6193a 100644 --- a/packages/lib/server-only/document/resend-document.ts +++ b/packages/lib/server-only/document/resend-document.ts @@ -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, diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx index b8a77ee7e..1356be3cc 100644 --- a/packages/lib/server-only/document/send-document.tsx +++ b/packages/lib/server-only/document/send-document.tsx @@ -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, diff --git a/packages/lib/server-only/field/sign-field-with-token.ts b/packages/lib/server-only/field/sign-field-with-token.ts index c3e18cb98..3739c9942 100644 --- a/packages/lib/server-only/field/sign-field-with-token.ts +++ b/packages/lib/server-only/field/sign-field-with-token.ts @@ -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`); } diff --git a/packages/lib/server-only/recipient/expire-recipient.ts b/packages/lib/server-only/recipient/expire-recipient.ts new file mode 100644 index 000000000..f11a74fc7 --- /dev/null +++ b/packages/lib/server-only/recipient/expire-recipient.ts @@ -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, + }, + }); +}; diff --git a/packages/lib/server-only/template/create-document-from-template.ts b/packages/lib/server-only/template/create-document-from-template.ts index 35946a155..05aa97512 100644 --- a/packages/lib/server-only/template/create-document-from-template.ts +++ b/packages/lib/server-only/template/create-document-from-template.ts @@ -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(), }; }), diff --git a/packages/lib/types/document.ts b/packages/lib/types/document.ts index b42801b72..1a690eec0 100644 --- a/packages/lib/types/document.ts +++ b/packages/lib/types/document.ts @@ -60,6 +60,8 @@ export const ZDocumentSchema = DocumentSchema.pick({ emailSettings: true, emailId: true, emailReplyTo: true, + expiryAmount: true, + expiryUnit: true, }).nullable(), folder: FolderSchema.pick({ id: true, diff --git a/packages/lib/utils/document.ts b/packages/lib/utils/document.ts index 8bae70ca2..55423fb93 100644 --- a/packages/lib/utils/document.ts +++ b/packages/lib/utils/document.ts @@ -15,6 +15,38 @@ export const isDocumentCompleted = (document: Pick | Documen return status === DocumentStatus.COMPLETED || status === DocumentStatus.REJECTED; }; +const getExpiryAmount = ( + meta: Partial | undefined | null, +): number | null => { + if (!meta) return null; + + if ('expiryAmount' in meta && meta.expiryAmount !== undefined) { + return meta.expiryAmount; + } + + if ('defaultExpiryAmount' in meta && meta.defaultExpiryAmount !== undefined) { + return meta.defaultExpiryAmount; + } + + return null; +}; + +const getExpiryUnit = ( + meta: Partial | undefined | null, +): string | null => { + if (!meta) return null; + + if ('expiryUnit' in meta && meta.expiryUnit !== undefined) { + return meta.expiryUnit; + } + + if ('defaultExpiryUnit' in meta && meta.defaultExpiryUnit !== undefined) { + return meta.defaultExpiryUnit; + } + + return null; +}; + /** * Extracts the derived document meta which should be used when creating a document * from scratch, or from a template. @@ -58,5 +90,9 @@ export const extractDerivedDocumentMeta = ( emailReplyTo: meta.emailReplyTo ?? settings.emailReplyTo, emailSettings: meta.emailSettings || settings.emailDocumentSettings || DEFAULT_DOCUMENT_EMAIL_SETTINGS, + + // Expiry settings. + expiryAmount: getExpiryAmount(meta), + expiryUnit: getExpiryUnit(meta), } satisfies Omit; }; diff --git a/packages/lib/utils/expiry.ts b/packages/lib/utils/expiry.ts new file mode 100644 index 000000000..fac0b75d7 --- /dev/null +++ b/packages/lib/utils/expiry.ts @@ -0,0 +1,50 @@ +import type { Recipient } from '@prisma/client'; +import { DateTime } from 'luxon'; + +export const calculateRecipientExpiry = ( + documentExpiryAmount?: number | null, + documentExpiryUnit?: string | null, + fromDate: Date = new Date(), +): Date | null => { + if (!documentExpiryAmount || !documentExpiryUnit) { + return null; + } + + switch (documentExpiryUnit) { + case 'minutes': + return DateTime.fromJSDate(fromDate).plus({ minutes: documentExpiryAmount }).toJSDate(); + case 'hours': + return DateTime.fromJSDate(fromDate).plus({ hours: documentExpiryAmount }).toJSDate(); + case 'days': + return DateTime.fromJSDate(fromDate).plus({ days: documentExpiryAmount }).toJSDate(); + case 'weeks': + return DateTime.fromJSDate(fromDate).plus({ weeks: documentExpiryAmount }).toJSDate(); + case 'months': + return DateTime.fromJSDate(fromDate).plus({ months: documentExpiryAmount }).toJSDate(); + default: + return DateTime.fromJSDate(fromDate).plus({ days: documentExpiryAmount }).toJSDate(); + } +}; + +export const isRecipientExpired = (recipient: Recipient): boolean => { + if (!recipient.expired) { + return false; + } + + return DateTime.now() > DateTime.fromJSDate(recipient.expired); +}; + +export const isValidExpirySettings = ( + expiryAmount?: number | null, + expiryUnit?: string | null, +): boolean => { + if (!expiryAmount || !expiryUnit) { + return true; + } + + return expiryAmount > 0 && ['minutes', 'hours', 'days', 'weeks', 'months'].includes(expiryUnit); +}; + +export const formatExpiryDate = (date: Date): string => { + return DateTime.fromJSDate(date).toFormat('MMM dd, yyyy HH:mm'); +}; diff --git a/packages/prisma/migrations/20250818135945_add_expiry_settings/migration.sql b/packages/prisma/migrations/20250818135945_add_expiry_settings/migration.sql new file mode 100644 index 000000000..85089b916 --- /dev/null +++ b/packages/prisma/migrations/20250818135945_add_expiry_settings/migration.sql @@ -0,0 +1,10 @@ +-- AlterEnum +ALTER TYPE "SigningStatus" ADD VALUE 'EXPIRED'; + +-- AlterTable +ALTER TABLE "DocumentMeta" ADD COLUMN "expiryAmount" INTEGER, +ADD COLUMN "expiryUnit" TEXT; + +-- AlterTable +ALTER TABLE "TemplateMeta" ADD COLUMN "defaultExpiryAmount" INTEGER, +ADD COLUMN "defaultExpiryUnit" TEXT; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 14eb7f626..97819c19c 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -472,6 +472,9 @@ model DocumentMeta { emailSettings Json? /// [DocumentEmailSettings] @zod.custom.use(ZDocumentEmailSettingsSchema) emailReplyTo String? emailId String? + + expiryAmount Int? + expiryUnit String? } enum ReadStatus { @@ -488,6 +491,7 @@ enum SigningStatus { NOT_SIGNED SIGNED REJECTED + EXPIRED } enum RecipientRole { @@ -854,6 +858,10 @@ model TemplateMeta { allowDictateNextSigner Boolean @default(false) distributionMethod DocumentDistributionMethod @default(EMAIL) + // Default expiry settings + defaultExpiryAmount Int? + defaultExpiryUnit String? + typedSignatureEnabled Boolean @default(true) uploadSignatureEnabled Boolean @default(true) drawSignatureEnabled Boolean @default(true) diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index e6da221ac..a833e813a 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -24,6 +24,7 @@ import { sendDocument } from '@documenso/lib/server-only/document/send-document' import { getTeamById } from '@documenso/lib/server-only/team/get-team'; import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions'; import { isDocumentCompleted } from '@documenso/lib/utils/document'; +import { isValidExpirySettings } from '@documenso/lib/utils/expiry'; import { authenticatedProcedure, procedure, router } from '../trpc'; import { downloadDocumentRoute } from './download-document'; @@ -284,8 +285,16 @@ export const documentRouter = router({ globalActionAuth, recipients, meta, + expiryAmount, + expiryUnit, } = input; + if ((expiryAmount || expiryUnit) && !isValidExpirySettings(expiryAmount, expiryUnit)) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Invalid expiry settings. Please check your expiry configuration.', + }); + } + const { remaining } = await getServerLimits({ userId: user.id, teamId }); if (remaining.documents <= 0) { @@ -316,6 +325,8 @@ export const documentRouter = router({ globalAccessAuth, globalActionAuth, recipients, + expiryAmount, + expiryUnit, }, meta, requestMetadata: ctx.metadata, @@ -345,7 +356,14 @@ export const documentRouter = router({ .input(ZCreateDocumentRequestSchema) .mutation(async ({ input, ctx }) => { const { user, teamId } = ctx; - const { title, documentDataId, timezone, folderId } = input; + const { title, documentDataId, timezone, folderId, expiryAmount, expiryUnit } = input; + + // Validate expiry settings + if ((expiryAmount || expiryUnit) && !isValidExpirySettings(expiryAmount, expiryUnit)) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Invalid expiry settings. Please check your expiry configuration.', + }); + } ctx.logger.info({ input: { @@ -371,6 +389,8 @@ export const documentRouter = router({ userTimezone: timezone, requestMetadata: ctx.metadata, folderId, + expiryAmount, + expiryUnit, }); }), diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index 36eca0e26..70afb3e4d 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -117,6 +117,16 @@ export const ZDocumentMetaUploadSignatureEnabledSchema = z .boolean() .describe('Whether to allow recipients to sign using an uploaded signature.'); +export const ZDocumentExpiryAmountSchema = z + .number() + .int() + .min(1) + .describe('The amount for expiry duration (e.g., 3 for "3 days").'); + +export const ZDocumentExpiryUnitSchema = z + .enum(['minutes', 'hours', 'days', 'weeks', 'months']) + .describe('The unit for expiry duration (e.g., "days" for "3 days").'); + export const ZFindDocumentsRequestSchema = ZFindSearchParamsSchema.extend({ templateId: z .number() @@ -200,6 +210,8 @@ export const ZCreateDocumentRequestSchema = z.object({ documentDataId: z.string().min(1), timezone: ZDocumentMetaTimezoneSchema.optional(), folderId: z.string().describe('The ID of the folder to create the document in').optional(), + expiryAmount: ZDocumentExpiryAmountSchema.optional(), + expiryUnit: ZDocumentExpiryUnitSchema.optional(), }); export const ZCreateDocumentV2RequestSchema = z.object({ @@ -209,6 +221,8 @@ export const ZCreateDocumentV2RequestSchema = z.object({ globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(), globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(), formValues: ZDocumentFormValuesSchema.optional(), + expiryAmount: ZDocumentExpiryAmountSchema.optional(), + expiryUnit: ZDocumentExpiryUnitSchema.optional(), recipients: z .array( ZCreateRecipientSchema.extend({ diff --git a/packages/trpc/server/document-router/update-document.ts b/packages/trpc/server/document-router/update-document.ts index 44a7fb990..7b6df6fa0 100644 --- a/packages/trpc/server/document-router/update-document.ts +++ b/packages/trpc/server/document-router/update-document.ts @@ -1,5 +1,7 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta'; import { updateDocument } from '@documenso/lib/server-only/document/update-document'; +import { isValidExpirySettings } from '@documenso/lib/utils/expiry'; import { authenticatedProcedure } from '../trpc'; import { @@ -27,6 +29,15 @@ export const updateDocumentRoute = authenticatedProcedure const userId = ctx.user.id; + if ( + (meta.expiryAmount || meta.expiryUnit) && + !isValidExpirySettings(meta.expiryAmount, meta.expiryUnit) + ) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Invalid expiry settings. Please check your expiry configuration.', + }); + } + if (Object.values(meta).length > 0) { await upsertDocumentMeta({ userId: ctx.user.id, @@ -47,6 +58,8 @@ export const updateDocumentRoute = authenticatedProcedure emailId: meta.emailId, emailReplyTo: meta.emailReplyTo, emailSettings: meta.emailSettings, + expiryAmount: meta.expiryAmount, + expiryUnit: meta.expiryUnit, requestMetadata: ctx.metadata, }); } diff --git a/packages/trpc/server/document-router/update-document.types.ts b/packages/trpc/server/document-router/update-document.types.ts index 03e5159e8..9ca4dfb56 100644 --- a/packages/trpc/server/document-router/update-document.types.ts +++ b/packages/trpc/server/document-router/update-document.types.ts @@ -11,6 +11,8 @@ import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-emai import type { TrpcRouteMeta } from '../trpc'; import { + ZDocumentExpiryAmountSchema, + ZDocumentExpiryUnitSchema, ZDocumentExternalIdSchema, ZDocumentMetaDateFormatSchema, ZDocumentMetaDistributionMethodSchema, @@ -64,6 +66,8 @@ export const ZUpdateDocumentRequestSchema = z.object({ emailId: z.string().nullish(), emailReplyTo: z.string().email().nullish(), emailSettings: ZDocumentEmailSettingsSchema.optional(), + expiryAmount: ZDocumentExpiryAmountSchema.optional(), + expiryUnit: ZDocumentExpiryUnitSchema.optional(), }) .optional(), }); diff --git a/packages/ui/primitives/date-time-picker.tsx b/packages/ui/primitives/date-time-picker.tsx new file mode 100644 index 000000000..9215ca7ba --- /dev/null +++ b/packages/ui/primitives/date-time-picker.tsx @@ -0,0 +1,131 @@ +'use client'; + +import React from 'react'; + +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { CalendarIcon } from 'lucide-react'; +import { DateTime } from 'luxon'; + +import { cn } from '../lib/utils'; +import { Button } from './button'; +import { Calendar } from './calendar'; +import { Input } from './input'; +import { Popover, PopoverContent, PopoverTrigger } from './popover'; + +export interface DateTimePickerProps { + value?: Date; + onChange?: (date: Date | undefined) => void; + placeholder?: string; + disabled?: boolean; + className?: string; + minDate?: Date; +} + +export const DateTimePicker = ({ + value, + onChange, + placeholder, + disabled = false, + className, + minDate = new Date(), +}: DateTimePickerProps) => { + const { _ } = useLingui(); + const [open, setOpen] = React.useState(false); + + const handleDateSelect = (selectedDate: Date | undefined) => { + if (!selectedDate) { + onChange?.(undefined); + return; + } + + if (value) { + const existingTime = DateTime.fromJSDate(value); + const newDateTime = DateTime.fromJSDate(selectedDate).set({ + hour: existingTime.hour, + minute: existingTime.minute, + }); + onChange?.(newDateTime.toJSDate()); + } else { + const now = DateTime.now(); + const newDateTime = DateTime.fromJSDate(selectedDate).set({ + hour: now.hour, + minute: now.minute, + }); + onChange?.(newDateTime.toJSDate()); + } + setOpen(false); + }; + + const handleTimeChange = (event: React.ChangeEvent) => { + const timeValue = event.target.value; + if (!timeValue || !value) return; + + const [hours, minutes] = timeValue.split(':').map(Number); + const newDateTime = DateTime.fromJSDate(value).set({ + hour: hours, + minute: minutes, + }); + + onChange?.(newDateTime.toJSDate()); + }; + + const formatDateTime = (date: Date) => { + return DateTime.fromJSDate(date).toFormat('MMM dd, yyyy'); + }; + + const formatTime = (date: Date) => { + return DateTime.fromJSDate(date).toFormat('HH:mm'); + }; + + return ( +
+ + + + + + { + return date < minDate; + } + } + initialFocus + /> + + + + {value && ( +
+ + at + + +
+ )} +
+ ); +}; diff --git a/packages/ui/primitives/document-flow/add-settings.tsx b/packages/ui/primitives/document-flow/add-settings.tsx index 3c06f9d1c..8a6cc1e58 100644 --- a/packages/ui/primitives/document-flow/add-settings.tsx +++ b/packages/ui/primitives/document-flow/add-settings.tsx @@ -11,7 +11,7 @@ import { TeamMemberRole, } from '@prisma/client'; import { InfoIcon } from 'lucide-react'; -import { useForm } from 'react-hook-form'; +import { useForm, useWatch } from 'react-hook-form'; import { match } from 'ts-pattern'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; @@ -56,6 +56,7 @@ import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combo import { DocumentSignatureSettingsTooltip } from '../../components/document/document-signature-settings-tooltip'; import { Combobox } from '../combobox'; +import { ExpirySettingsPicker } from '../expiry-settings-picker'; import { Input } from '../input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select'; import { useStep } from '../stepper'; @@ -71,6 +72,18 @@ import { } from './document-flow-root'; import type { DocumentFlowStep } from './types'; +const isExpiryUnit = ( + value: unknown, +): value is 'minutes' | 'hours' | 'days' | 'weeks' | 'months' => { + return ( + value === 'minutes' || + value === 'hours' || + value === 'days' || + value === 'weeks' || + value === 'months' + ); +}; + export type AddSettingsFormProps = { documentFlow: DocumentFlowStep; recipients: Recipient[]; @@ -98,6 +111,9 @@ export const AddSettingsFormPartial = ({ documentAuth: document.authOptions, }); + const documentExpiryUnit = document.documentMeta?.expiryUnit; + const initialExpiryUnit = isExpiryUnit(documentExpiryUnit) ? documentExpiryUnit : undefined; + const form = useForm({ resolver: zodResolver(ZAddSettingsFormSchema), defaultValues: { @@ -117,6 +133,8 @@ export const AddSettingsFormPartial = ({ redirectUrl: document.documentMeta?.redirectUrl ?? '', language: document.documentMeta?.language ?? 'en', signatureTypes: extractTeamSignatureSettings(document.documentMeta), + expiryAmount: document.documentMeta?.expiryAmount ?? undefined, + expiryUnit: initialExpiryUnit, }, }, }); @@ -127,6 +145,9 @@ export const AddSettingsFormPartial = ({ (recipient) => recipient.sendStatus === SendStatus.SENT, ); + const expiryAmount = useWatch({ control: form.control, name: 'meta.expiryAmount' }); + const expiryUnit = useWatch({ control: form.control, name: 'meta.expiryUnit' }); + const canUpdateVisibility = match(currentTeamMemberRole) .with(TeamMemberRole.ADMIN, () => true) .with( @@ -469,6 +490,33 @@ export const AddSettingsFormPartial = ({ )} /> + +
+ + Link Expiry + + { + if (value.expiryDuration) { + form.setValue('meta.expiryAmount', value.expiryDuration.amount); + form.setValue('meta.expiryUnit', value.expiryDuration.unit); + } else { + form.setValue('meta.expiryAmount', undefined); + form.setValue('meta.expiryUnit', undefined); + } + }} + /> +
diff --git a/packages/ui/primitives/document-flow/add-settings.types.ts b/packages/ui/primitives/document-flow/add-settings.types.ts index ea70556f0..a1fbdfd6a 100644 --- a/packages/ui/primitives/document-flow/add-settings.types.ts +++ b/packages/ui/primitives/document-flow/add-settings.types.ts @@ -46,6 +46,8 @@ export const ZAddSettingsFormSchema = z.object({ signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, { message: msg`At least one signature type must be enabled`.id, }), + expiryAmount: z.number().int().min(1).optional(), + expiryUnit: z.enum(['minutes', 'hours', 'days', 'weeks', 'months']).optional(), }), }); diff --git a/packages/ui/primitives/duration-selector.tsx b/packages/ui/primitives/duration-selector.tsx new file mode 100644 index 000000000..e1e5ba0d5 --- /dev/null +++ b/packages/ui/primitives/duration-selector.tsx @@ -0,0 +1,106 @@ +'use client'; + +import React from 'react'; + +import { useLingui } from '@lingui/react'; +import { DateTime } from 'luxon'; + +import { cn } from '../lib/utils'; +import { Input } from './input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select'; + +export type TimeUnit = 'minutes' | 'hours' | 'days' | 'weeks' | 'months'; + +export interface DurationValue { + amount: number; + unit: TimeUnit; +} + +export interface DurationSelectorProps { + value?: DurationValue; + onChange?: (value: DurationValue) => void; + disabled?: boolean; + className?: string; + minAmount?: number; + maxAmount?: number; +} + +const TIME_UNITS: Array<{ value: TimeUnit; label: string; labelPlural: string }> = [ + { value: 'minutes', label: 'Minute', labelPlural: 'Minutes' }, + { value: 'hours', label: 'Hour', labelPlural: 'Hours' }, + { value: 'days', label: 'Day', labelPlural: 'Days' }, + { value: 'weeks', label: 'Week', labelPlural: 'Weeks' }, + { value: 'months', label: 'Month', labelPlural: 'Months' }, +]; + +export const DurationSelector = ({ + value = { amount: 1, unit: 'days' }, + onChange, + disabled = false, + className, + minAmount = 1, + maxAmount = 365, +}: DurationSelectorProps) => { + const { _ } = useLingui(); + + const handleAmountChange = (event: React.ChangeEvent) => { + const amount = parseInt(event.target.value, 10); + if (!isNaN(amount) && amount >= minAmount && amount <= maxAmount) { + onChange?.({ ...value, amount }); + } + }; + + const handleUnitChange = (unit: TimeUnit) => { + onChange?.({ ...value, unit }); + }; + + const getUnitLabel = (unit: TimeUnit, amount: number) => { + const unitConfig = TIME_UNITS.find((u) => u.value === unit); + if (!unitConfig) return unit; + + return amount === 1 ? unitConfig.label : unitConfig.labelPlural; + }; + + return ( +
+ + +
+ ); +}; + +export const calculateExpiryDate = (duration: DurationValue, fromDate: Date = new Date()): Date => { + switch (duration.unit) { + case 'minutes': + return DateTime.fromJSDate(fromDate).plus({ minutes: duration.amount }).toJSDate(); + case 'hours': + return DateTime.fromJSDate(fromDate).plus({ hours: duration.amount }).toJSDate(); + case 'days': + return DateTime.fromJSDate(fromDate).plus({ days: duration.amount }).toJSDate(); + case 'weeks': + return DateTime.fromJSDate(fromDate).plus({ weeks: duration.amount }).toJSDate(); + case 'months': + return DateTime.fromJSDate(fromDate).plus({ months: duration.amount }).toJSDate(); + default: + return DateTime.fromJSDate(fromDate).plus({ days: duration.amount }).toJSDate(); + } +}; diff --git a/packages/ui/primitives/expiry-settings-picker.tsx b/packages/ui/primitives/expiry-settings-picker.tsx new file mode 100644 index 000000000..28d038b4d --- /dev/null +++ b/packages/ui/primitives/expiry-settings-picker.tsx @@ -0,0 +1,121 @@ +'use client'; + +import React from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { cn } from '../lib/utils'; +import { DurationSelector } from './duration-selector'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from './form/form'; + +const ZExpirySettingsSchema = z.object({ + expiryDuration: z + .object({ + amount: z.number().int().min(1), + unit: z.enum(['minutes', 'hours', 'days', 'weeks', 'months']), + }) + .optional(), +}); + +export type ExpirySettings = z.infer; + +export interface ExpirySettingsPickerProps { + className?: string; + defaultValues?: Partial; + disabled?: boolean; + onValueChange?: (value: ExpirySettings) => void; + value?: ExpirySettings; +} + +export const ExpirySettingsPicker = ({ + className, + defaultValues = { + expiryDuration: undefined, + }, + disabled = false, + onValueChange, + value, +}: ExpirySettingsPickerProps) => { + const { _ } = useLingui(); + + const form = useForm({ + resolver: zodResolver(ZExpirySettingsSchema), + defaultValues, + mode: 'onChange', + }); + + const { watch, setValue, getValues } = form; + const expiryDuration = watch('expiryDuration'); + + // Call onValueChange when form values change + React.useEffect(() => { + const subscription = watch((value) => { + if (onValueChange) { + onValueChange(value as ExpirySettings); + } + }); + return () => subscription.unsubscribe(); + }, [watch, onValueChange]); + + // Keep internal form state in sync when a controlled value is provided + React.useEffect(() => { + if (value === undefined) return; + + const current = getValues('expiryDuration'); + const next = value.expiryDuration; + + const amountsDiffer = (current?.amount ?? null) !== (next?.amount ?? null); + const unitsDiffer = (current?.unit ?? null) !== (next?.unit ?? null); + + if (amountsDiffer || unitsDiffer) { + setValue('expiryDuration', next, { + shouldDirty: false, + shouldTouch: false, + shouldValidate: false, + }); + } + }, [value, getValues, setValue]); + + return ( +
+
+ ( + + + Link Expiry + + + Set an expiry duration for signing links (leave empty to disable) + + + + + + + )} + /> + +
+ ); +};