feat: cap automated reminders before resend (#3016)

Stop sending automated reminders after a configurable threshold
(default 5) and reset the count on manual resend.
This commit is contained in:
Lucas Smith
2026-06-23 15:11:52 +10:00
committed by GitHub
parent f2525ae95b
commit 04f6e76178
6 changed files with 52 additions and 17 deletions
+15 -4
View File
@@ -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<TEnvelopeReminderDurationPeriod['unit'], keyof DurationLikeObject> = {
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;
@@ -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,
});
}
};
@@ -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;
@@ -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<typeof ZEnvelopeReminderSettings.parse> | 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({
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Recipient" ADD COLUMN "reminderCount" INTEGER NOT NULL DEFAULT 0;
+1
View File
@@ -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?