From 04f6e761781aef9e7ec33f05133c0cdc346882e2 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Tue, 23 Jun 2026 15:11:52 +1000 Subject: [PATCH] feat: cap automated reminders before resend (#3016) Stop sending automated reminders after a configurable threshold (default 5) and reset the count on manual resend. --- packages/lib/constants/envelope-reminder.ts | 19 +++++++++++---- .../process-signing-reminder.handler.ts | 5 +++- .../server-only/document/resend-document.ts | 19 +++++++++++++-- .../update-recipient-next-reminder.ts | 23 +++++++++++-------- .../migration.sql | 2 ++ packages/prisma/schema.prisma | 1 + 6 files changed, 52 insertions(+), 17 deletions(-) create mode 100644 packages/prisma/migrations/20260622120000_add_recipient_reminder_count/migration.sql diff --git a/packages/lib/constants/envelope-reminder.ts b/packages/lib/constants/envelope-reminder.ts index 0eb8cdf93..b000ed844 100644 --- a/packages/lib/constants/envelope-reminder.ts +++ b/packages/lib/constants/envelope-reminder.ts @@ -36,6 +36,12 @@ export const DEFAULT_ENVELOPE_REMINDER_SETTINGS: TEnvelopeReminderSettings = { */ export const MAX_REMINDER_WINDOW_DAYS = 30; +/** + * Maximum number of automated reminders sent to a recipient before reminders + * stop. A manual resend resets the count, re-arming reminders. + */ +export const MAX_REMINDERS_BEFORE_RESEND = 5; + const UNIT_TO_LUXON_KEY: Record = { day: 'days', week: 'weeks', @@ -53,24 +59,29 @@ export const getEnvelopeReminderDuration = (period: TEnvelopeReminderDurationPer * - `{ sendAfter: { disabled: true }, ... }` means never send the first reminder. * - `{ repeatEvery: { disabled: true }, ... }` means don't repeat after the first reminder. * - * A hard cap of `MAX_REMINDER_WINDOW_DAYS` days from `sentAt` is enforced — - * any computed reminder beyond that point returns null so reminders stop. + * Reminders stop (returns null) once either cap is hit: `MAX_REMINDER_WINDOW_DAYS` + * from `sentAt`, or `MAX_REMINDERS_BEFORE_RESEND` reminders already sent. * * `sentAt` is when the signing request was sent to this specific recipient. * - * Returns the next Date the reminder should be sent, or null if no reminder should be sent. + * Returns the next Date the reminder should be sent, or null if none. */ export const resolveNextReminderAt = (options: { config: TEnvelopeReminderSettings | null; sentAt: Date; lastReminderSentAt: Date | null; + reminderCount: number; }): Date | null => { - const { config, sentAt, lastReminderSentAt } = options; + const { config, sentAt, lastReminderSentAt, reminderCount } = options; if (!config) { return null; } + if (reminderCount >= MAX_REMINDERS_BEFORE_RESEND) { + return null; + } + const maxReminderAt = new Date(sentAt.getTime() + Duration.fromObject({ days: MAX_REMINDER_WINDOW_DAYS }).toMillis()); let candidate: Date; diff --git a/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts b/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts index 7f73e9542..2d08d7c36 100644 --- a/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts +++ b/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts @@ -52,6 +52,7 @@ export const run = async ({ payload, io }: { payload: TProcessSigningReminderJob data: { lastReminderSentAt: now, nextReminderAt: null, + reminderCount: { increment: 1 }, }, }); @@ -243,13 +244,15 @@ export const run = async ({ payload, io }: { payload: TProcessSigningReminderJob }); } - // Compute the next reminder time (repeat interval). + // reminderCount was incremented in the atomic claim above, so the value read + // here includes the reminder we just sent and gates the next one. if (recipient.sentAt) { await updateRecipientNextReminder({ recipientId: recipient.id, envelopeId: envelope.id, sentAt: recipient.sentAt, lastReminderSentAt: now, + reminderCount: recipient.reminderCount, }); } }; diff --git a/packages/lib/server-only/document/resend-document.ts b/packages/lib/server-only/document/resend-document.ts index c270199a3..29b20ed15 100644 --- a/packages/lib/server-only/document/resend-document.ts +++ b/packages/lib/server-only/document/resend-document.ts @@ -31,6 +31,7 @@ import { buildEnvelopeEmailHeaders } from '../email/build-envelope-email-headers import { getEmailContext } from '../email/get-email-context'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits'; +import { updateRecipientNextReminder } from '../recipient/update-recipient-next-reminder'; import { assertUserNotDisabled } from '../user/assert-user-not-disabled'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; @@ -118,7 +119,6 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe }); } - // Refresh the expiresAt on each resent recipient. const expiresAt = resolveExpiresAt(envelope.documentMeta?.envelopeExpirationPeriod ?? null); const recipientsToRemind = envelope.recipients.filter( @@ -128,7 +128,6 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe recipient.role !== RecipientRole.CC, ); - // Extend the expiration deadline for recipients being resent. if (expiresAt && recipientsToRemind.length > 0) { await prisma.recipient.updateMany({ where: { @@ -143,6 +142,22 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe }); } + // A manual resend restarts the reminder cycle from scratch, mirroring the + // initial send, so a recipient that hit the threshold can be reminded again. + const resentAt = new Date(); + + await Promise.all( + recipientsToRemind.map((recipient) => + updateRecipientNextReminder({ + recipientId: recipient.id, + envelopeId: envelope.id, + sentAt: resentAt, + lastReminderSentAt: null, + resetReminderCount: true, + }), + ), + ); + const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings( envelope.documentMeta, ).recipientSigningRequest; diff --git a/packages/lib/server-only/recipient/update-recipient-next-reminder.ts b/packages/lib/server-only/recipient/update-recipient-next-reminder.ts index 142cc6a3f..117c149cb 100644 --- a/packages/lib/server-only/recipient/update-recipient-next-reminder.ts +++ b/packages/lib/server-only/recipient/update-recipient-next-reminder.ts @@ -6,22 +6,20 @@ import { resolveNextReminderAt, ZEnvelopeReminderSettings } from '../../constant /** * Compute and store `nextReminderAt` for a single recipient. * - * Call this after: - * - Sending the signing email (sentAt is set) - * - Sending a reminder (lastReminderSentAt is updated) - * - * If `reminderSettings` is provided it's used directly, avoiding a query. - * Otherwise it's read from the envelope's documentMeta (already resolved - * from the org/team cascade at envelope creation time). + * Pass `resetReminderCount: true` to restart the reminder cycle (e.g. on a + * manual resend): the count is zeroed and the schedule recomputed as if the + * request was freshly sent at `sentAt`. */ export const updateRecipientNextReminder = async (options: { recipientId: number; envelopeId: string; sentAt: Date; lastReminderSentAt: Date | null; + reminderCount?: number; + resetReminderCount?: boolean; reminderSettings?: ReturnType | null; }) => { - const { recipientId, envelopeId, sentAt, lastReminderSentAt } = options; + const { recipientId, envelopeId, sentAt, lastReminderSentAt, reminderCount = 0, resetReminderCount } = options; let settings = options.reminderSettings; @@ -40,11 +38,15 @@ export const updateRecipientNextReminder = async (options: { config: settings, sentAt, lastReminderSentAt, + reminderCount: resetReminderCount ? 0 : reminderCount, }); await prisma.recipient.update({ where: { id: recipientId }, - data: { nextReminderAt }, + data: { + nextReminderAt, + ...(resetReminderCount ? { reminderCount: 0 } : {}), + }, }); }; @@ -82,7 +84,7 @@ export const recomputeNextReminderForEnvelope = async (envelopeId: string) => { // Don't reschedule reminders for recipients whose deadline has passed. OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], }, - select: { id: true, sentAt: true, lastReminderSentAt: true }, + select: { id: true, sentAt: true, lastReminderSentAt: true, reminderCount: true }, }); await Promise.all( @@ -95,6 +97,7 @@ export const recomputeNextReminderForEnvelope = async (envelopeId: string) => { config: settings, sentAt: recipient.sentAt, lastReminderSentAt: recipient.lastReminderSentAt, + reminderCount: recipient.reminderCount, }); await prisma.recipient.update({ diff --git a/packages/prisma/migrations/20260622120000_add_recipient_reminder_count/migration.sql b/packages/prisma/migrations/20260622120000_add_recipient_reminder_count/migration.sql new file mode 100644 index 000000000..ad61885f3 --- /dev/null +++ b/packages/prisma/migrations/20260622120000_add_recipient_reminder_count/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Recipient" ADD COLUMN "reminderCount" INTEGER NOT NULL DEFAULT 0; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 3cafdb55f..06be2eed9 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -642,6 +642,7 @@ model Recipient { signedAt DateTime? lastReminderSentAt DateTime? nextReminderAt DateTime? + reminderCount Int @default(0) authOptions Json? /// [RecipientAuthOptions] @zod.custom.use(ZRecipientAuthOptionsSchema) signingOrder Int? /// @zod.number.describe("The order in which the recipient should sign the document. Only works if the document is set to sequential signing.") rejectionReason String?