mirror of
https://github.com/documenso/documenso.git
synced 2026-07-27 10:25:00 +10:00
docs: add signing reminders guide (#2716)
This commit is contained in:
@@ -31,6 +31,14 @@ export const DEFAULT_ENVELOPE_REMINDER_SETTINGS: TEnvelopeReminderSettings = {
|
||||
repeatEvery: { unit: 'day', amount: 2 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Hard upper bound on the window in which automated reminders may be sent,
|
||||
* measured from the moment the signing request was first sent to the
|
||||
* recipient. Prevents runaway reminder chains for recipients with no
|
||||
* expiration set who never sign.
|
||||
*/
|
||||
export const MAX_REMINDER_WINDOW_DAYS = 30;
|
||||
|
||||
const UNIT_TO_LUXON_KEY: Record<TEnvelopeReminderDurationPeriod['unit'], keyof DurationLikeObject> =
|
||||
{
|
||||
day: 'days',
|
||||
@@ -49,6 +57,9 @@ 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.
|
||||
*
|
||||
* `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.
|
||||
@@ -64,6 +75,12 @@ export const resolveNextReminderAt = (options: {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxReminderAt = new Date(
|
||||
sentAt.getTime() + Duration.fromObject({ days: MAX_REMINDER_WINDOW_DAYS }).toMillis(),
|
||||
);
|
||||
|
||||
let candidate: Date;
|
||||
|
||||
// If we haven't sent the first reminder yet, use sendAfter.
|
||||
if (!lastReminderSentAt) {
|
||||
if ('disabled' in config.sendAfter) {
|
||||
@@ -72,15 +89,22 @@ export const resolveNextReminderAt = (options: {
|
||||
|
||||
const delay = getEnvelopeReminderDuration(config.sendAfter);
|
||||
|
||||
return new Date(sentAt.getTime() + delay.toMillis());
|
||||
candidate = new Date(sentAt.getTime() + delay.toMillis());
|
||||
} else {
|
||||
// For subsequent reminders, use repeatEvery.
|
||||
if ('disabled' in config.repeatEvery) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const interval = getEnvelopeReminderDuration(config.repeatEvery);
|
||||
|
||||
candidate = new Date(lastReminderSentAt.getTime() + interval.toMillis());
|
||||
}
|
||||
|
||||
// For subsequent reminders, use repeatEvery.
|
||||
if ('disabled' in config.repeatEvery) {
|
||||
// Stop if the candidate is past the hard cap measured from sentAt.
|
||||
if (candidate.getTime() > maxReminderAt.getTime()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const interval = getEnvelopeReminderDuration(config.repeatEvery);
|
||||
|
||||
return new Date(lastReminderSentAt.getTime() + interval.toMillis());
|
||||
return candidate;
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ const BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION_SCHEMA = z.object({
|
||||
embedSigningWhiteLabel: z.literal(true).optional(),
|
||||
cfr21: z.literal(true).optional(),
|
||||
hipaa: z.literal(true).optional(),
|
||||
signingReminders: z.literal(true).optional(),
|
||||
// Todo: Envelopes - Do we need to check?
|
||||
// authenticationPortal & emailDomains missing here.
|
||||
}),
|
||||
|
||||
@@ -44,13 +44,16 @@ export const run = async ({
|
||||
const now = new Date();
|
||||
|
||||
// Atomically claim this reminder by setting lastReminderSentAt and clearing
|
||||
// nextReminderAt so no other sweep picks it up.
|
||||
// nextReminderAt so no other sweep picks it up. The expiration filter
|
||||
// guards against races where the expiration sweep hasn't yet flagged
|
||||
// a recipient whose deadline has already passed.
|
||||
const updatedCount = await prisma.recipient.updateMany({
|
||||
where: {
|
||||
id: recipientId,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
role: { not: RecipientRole.CC },
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||
envelope: {
|
||||
status: DocumentStatus.PENDING,
|
||||
deletedAt: null,
|
||||
|
||||
@@ -20,6 +20,11 @@ export const run = async ({
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
role: { not: RecipientRole.CC },
|
||||
// Skip recipients whose signing deadline has passed. `expiresAt`
|
||||
// is the source of truth — the expiration sweep asynchronously
|
||||
// sets `expirationNotifiedAt`, so filtering on `expiresAt` also
|
||||
// covers the window before the expiration sweep runs.
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||
envelope: {
|
||||
status: DocumentStatus.PENDING,
|
||||
deletedAt: null,
|
||||
|
||||
@@ -80,6 +80,8 @@ export const recomputeNextReminderForEnvelope = async (envelopeId: string) => {
|
||||
? ZEnvelopeReminderSettings.parse(envelope.documentMeta.reminderSettings)
|
||||
: null;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
envelopeId,
|
||||
@@ -87,6 +89,8 @@ export const recomputeNextReminderForEnvelope = async (envelopeId: string) => {
|
||||
sendStatus: SendStatus.SENT,
|
||||
sentAt: { not: null },
|
||||
role: { not: RecipientRole.CC },
|
||||
// Don't reschedule reminders for recipients whose deadline has passed.
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||
},
|
||||
select: { id: true, sentAt: true, lastReminderSentAt: true },
|
||||
});
|
||||
|
||||
@@ -34,6 +34,8 @@ export const ZClaimFlagsSchema = z.object({
|
||||
authenticationPortal: z.boolean().optional(),
|
||||
|
||||
allowLegacyEnvelopes: z.boolean().optional(),
|
||||
|
||||
signingReminders: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type TClaimFlags = z.infer<typeof ZClaimFlagsSchema>;
|
||||
@@ -101,6 +103,10 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
|
||||
key: 'allowLegacyEnvelopes',
|
||||
label: 'Allow Legacy Envelopes',
|
||||
},
|
||||
signingReminders: {
|
||||
key: 'signingReminders',
|
||||
label: 'Signing reminders',
|
||||
},
|
||||
};
|
||||
|
||||
export enum INTERNAL_CLAIM_ID {
|
||||
@@ -137,6 +143,7 @@ export const internalClaims: InternalClaims = {
|
||||
locked: true,
|
||||
flags: {
|
||||
unlimitedDocuments: true,
|
||||
signingReminders: true,
|
||||
},
|
||||
},
|
||||
[INTERNAL_CLAIM_ID.TEAM]: {
|
||||
@@ -150,6 +157,7 @@ export const internalClaims: InternalClaims = {
|
||||
unlimitedDocuments: true,
|
||||
allowCustomBranding: true,
|
||||
embedSigning: true,
|
||||
signingReminders: true,
|
||||
},
|
||||
},
|
||||
[INTERNAL_CLAIM_ID.PLATFORM]: {
|
||||
@@ -168,6 +176,7 @@ export const internalClaims: InternalClaims = {
|
||||
embedAuthoringWhiteLabel: true,
|
||||
embedSigning: false,
|
||||
embedSigningWhiteLabel: true,
|
||||
signingReminders: true,
|
||||
},
|
||||
},
|
||||
[INTERNAL_CLAIM_ID.ENTERPRISE]: {
|
||||
@@ -188,6 +197,7 @@ export const internalClaims: InternalClaims = {
|
||||
embedSigningWhiteLabel: true,
|
||||
cfr21: true,
|
||||
authenticationPortal: true,
|
||||
signingReminders: true,
|
||||
},
|
||||
},
|
||||
[INTERNAL_CLAIM_ID.EARLY_ADOPTER]: {
|
||||
@@ -203,6 +213,7 @@ export const internalClaims: InternalClaims = {
|
||||
hidePoweredBy: true,
|
||||
embedSigning: true,
|
||||
embedSigningWhiteLabel: true,
|
||||
signingReminders: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user