From d6bc4bd0baa13af399ac60db0f04f27d33fc3733 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Tue, 19 Aug 2025 10:20:12 +0000 Subject: [PATCH] chore: review --- .../_recipient+/sign.$token+/_index.tsx | 6 ++-- .../hooks/use-element-scale-size.ts | 2 +- .../server-only/document/resend-document.ts | 16 +++++++++ packages/lib/types/document-audit-logs.ts | 16 +++++++++ packages/lib/utils/document-audit-logs.ts | 4 +++ packages/lib/utils/expiry.ts | 22 ++++++++++++ packages/ui/primitives/duration-selector.tsx | 35 +++---------------- .../ui/primitives/expiry-settings-picker.tsx | 17 +++++++-- 8 files changed, 81 insertions(+), 37 deletions(-) diff --git a/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx b/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx index eb6113536..bd25b9995 100644 --- a/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx +++ b/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx @@ -130,8 +130,10 @@ export async function loader({ params, request }: Route.LoaderArgs) { const { documentMeta } = document; if (isRecipientExpired(recipient)) { - await expireRecipient({ recipientId: recipient.id }); - throw redirect(`/sign/${token}/expired`); + const expiredRecipient = await expireRecipient({ recipientId: recipient.id }); + if (expiredRecipient) { + throw redirect(`/sign/${token}/expired`); + } } if (recipient.signingStatus === SigningStatus.REJECTED) { diff --git a/packages/lib/client-only/hooks/use-element-scale-size.ts b/packages/lib/client-only/hooks/use-element-scale-size.ts index 1c8ab320e..842586639 100644 --- a/packages/lib/client-only/hooks/use-element-scale-size.ts +++ b/packages/lib/client-only/hooks/use-element-scale-size.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/consistent-type-assertions */ -import { RefObject, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; /** * Calculate the width and height of a text element. diff --git a/packages/lib/server-only/document/resend-document.ts b/packages/lib/server-only/document/resend-document.ts index b63c6193a..3531bb3b9 100644 --- a/packages/lib/server-only/document/resend-document.ts +++ b/packages/lib/server-only/document/resend-document.ts @@ -201,6 +201,7 @@ export const resendDocument = async ({ }); if (document.documentMeta?.expiryAmount && document.documentMeta?.expiryUnit) { + const previousExpiryDate = recipient.expired; const newExpiryDate = calculateRecipientExpiry( document.documentMeta.expiryAmount, document.documentMeta.expiryUnit, @@ -215,6 +216,21 @@ export const resendDocument = async ({ expired: newExpiryDate, }, }); + + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRY_EXTENDED, + documentId: document.id, + metadata: requestMetadata, + data: { + recipientId: recipient.id, + recipientName: recipient.name, + recipientEmail: recipient.email, + previousExpiryDate, + newExpiryDate, + }, + }), + }); } await tx.documentAuditLog.create({ diff --git a/packages/lib/types/document-audit-logs.ts b/packages/lib/types/document-audit-logs.ts index e11f9b31b..1074c4bfa 100644 --- a/packages/lib/types/document-audit-logs.ts +++ b/packages/lib/types/document-audit-logs.ts @@ -40,6 +40,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([ 'DOCUMENT_TITLE_UPDATED', // When the document title is updated. 'DOCUMENT_EXTERNAL_ID_UPDATED', // When the document external ID is updated. 'DOCUMENT_MOVED_TO_TEAM', // When the document is moved to a team. + 'DOCUMENT_RECIPIENT_EXPIRY_EXTENDED', // When a recipient's expiry is extended via resend. ]); export const ZDocumentAuditLogEmailTypeSchema = z.enum([ @@ -598,6 +599,20 @@ export const ZDocumentAuditLogEventDocumentMovedToTeamSchema = z.object({ }), }); +/** + * Event: Recipient expiry extended. + */ +export const ZDocumentAuditLogEventRecipientExpiryExtendedSchema = z.object({ + type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRY_EXTENDED), + data: z.object({ + recipientId: z.number(), + recipientName: z.string().optional(), + recipientEmail: z.string(), + previousExpiryDate: z.date().nullable(), + newExpiryDate: z.date().nullable(), + }), +}); + export const ZDocumentAuditLogBaseSchema = z.object({ id: z.string(), createdAt: z.date(), @@ -636,6 +651,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and( ZDocumentAuditLogEventRecipientAddedSchema, ZDocumentAuditLogEventRecipientUpdatedSchema, ZDocumentAuditLogEventRecipientRemovedSchema, + ZDocumentAuditLogEventRecipientExpiryExtendedSchema, ]), ); diff --git a/packages/lib/utils/document-audit-logs.ts b/packages/lib/utils/document-audit-logs.ts index fe4c43e1d..a9563a86d 100644 --- a/packages/lib/utils/document-audit-logs.ts +++ b/packages/lib/utils/document-audit-logs.ts @@ -492,6 +492,10 @@ export const formatDocumentAuditLogAction = ( context: `Audit log format`, }), })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRY_EXTENDED }, (data) => ({ + anonymous: msg`Recipient expiry extended`, + identified: msg`${prefix} extended expiry for ${data.data.recipientEmail}`, + })) .exhaustive(); return { diff --git a/packages/lib/utils/expiry.ts b/packages/lib/utils/expiry.ts index fac0b75d7..e5cb815f8 100644 --- a/packages/lib/utils/expiry.ts +++ b/packages/lib/utils/expiry.ts @@ -1,6 +1,11 @@ import type { Recipient } from '@prisma/client'; import { DateTime } from 'luxon'; +export interface DurationValue { + amount: number; + unit: string; +} + export const calculateRecipientExpiry = ( documentExpiryAmount?: number | null, documentExpiryUnit?: string | null, @@ -45,6 +50,23 @@ export const isValidExpirySettings = ( return expiryAmount > 0 && ['minutes', 'hours', 'days', 'weeks', 'months'].includes(expiryUnit); }; +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(); + } +}; + export const formatExpiryDate = (date: Date): string => { return DateTime.fromJSDate(date).toFormat('MMM dd, yyyy HH:mm'); }; diff --git a/packages/ui/primitives/duration-selector.tsx b/packages/ui/primitives/duration-selector.tsx index e1e5ba0d5..869d1e546 100644 --- a/packages/ui/primitives/duration-selector.tsx +++ b/packages/ui/primitives/duration-selector.tsx @@ -2,20 +2,12 @@ import React from 'react'; -import { useLingui } from '@lingui/react'; -import { DateTime } from 'luxon'; +import type { DurationValue } from '@documenso/lib/utils/expiry'; 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; @@ -25,7 +17,7 @@ export interface DurationSelectorProps { maxAmount?: number; } -const TIME_UNITS: Array<{ value: TimeUnit; label: string; labelPlural: string }> = [ +const TIME_UNITS: Array<{ value: string; label: string; labelPlural: string }> = [ { value: 'minutes', label: 'Minute', labelPlural: 'Minutes' }, { value: 'hours', label: 'Hour', labelPlural: 'Hours' }, { value: 'days', label: 'Day', labelPlural: 'Days' }, @@ -41,8 +33,6 @@ export const DurationSelector = ({ 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) { @@ -50,11 +40,11 @@ export const DurationSelector = ({ } }; - const handleUnitChange = (unit: TimeUnit) => { + const handleUnitChange = (unit: string) => { onChange?.({ ...value, unit }); }; - const getUnitLabel = (unit: TimeUnit, amount: number) => { + const getUnitLabel = (unit: string, amount: number) => { const unitConfig = TIME_UNITS.find((u) => u.value === unit); if (!unitConfig) return unit; @@ -87,20 +77,3 @@ export const DurationSelector = ({ ); }; - -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 index 28d038b4d..1d4bfac59 100644 --- a/packages/ui/primitives/expiry-settings-picker.tsx +++ b/packages/ui/primitives/expiry-settings-picker.tsx @@ -3,11 +3,12 @@ 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 { calculateExpiryDate, formatExpiryDate } from '@documenso/lib/utils/expiry'; + import { cn } from '../lib/utils'; import { DurationSelector } from './duration-selector'; import { @@ -48,8 +49,6 @@ export const ExpirySettingsPicker = ({ onValueChange, value, }: ExpirySettingsPickerProps) => { - const { _ } = useLingui(); - const form = useForm({ resolver: zodResolver(ZExpirySettingsSchema), defaultValues, @@ -59,6 +58,13 @@ export const ExpirySettingsPicker = ({ const { watch, setValue, getValues } = form; const expiryDuration = watch('expiryDuration'); + const calculatedExpiryDate = React.useMemo(() => { + if (expiryDuration?.amount && expiryDuration?.unit) { + return calculateExpiryDate(expiryDuration); + } + return null; + }, [expiryDuration]); + // Call onValueChange when form values change React.useEffect(() => { const subscription = watch((value) => { @@ -111,6 +117,11 @@ export const ExpirySettingsPicker = ({ maxAmount={365} /> + {calculatedExpiryDate && ( + + Links will expire on: {formatExpiryDate(calculatedExpiryDate)} + + )} )}