Files
pn-new-crm/src/lib/services/document-reminders.ts
Matt Ciaccio 978df1c4d7 feat(reminders): cadence-aware framework with auto/manual modes
isReminderDue now keys off doc.remindersDisabled and the effective
cadence (per-doc override → template default), dropping the implicit
interests.reminderEnabled gate so non-EOI docs auto-remind correctly.
sendReminderIfAllowed gains an options bag — auto:true keeps the 9-16
window + cadence cooldown for the cron, auto:false bypasses both for
manual UI sends. signerId targets a specific pending signer (must be
next in sequential mode). 7 unit tests cover the cadence math.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:50:00 +02:00

212 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { and, eq, inArray, isNotNull, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import {
documents,
documentSigners,
documentEvents,
documentTemplates,
} from '@/lib/db/schema/documents';
import { ports } from '@/lib/db/schema/ports';
import { sendReminder as documensoRemind } from '@/lib/services/documenso-client';
import { logger } from '@/lib/logger';
// ─── Helpers ─────────────────────────────────────────────────────────────────
function getCurrentHourInTimezone(timezone: string): number {
const now = new Date();
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
hour: 'numeric',
hour12: false,
});
return parseInt(formatter.format(now), 10);
}
interface ReminderEligibilityArgs {
status: string;
documensoId: string | null;
remindersDisabled: boolean;
reminderCadenceOverride: number | null;
templateCadenceDays: number | null;
lastReminderAt: Date | null;
now?: Date;
}
/**
* Pure cadence/disable check used by the cron path. Auto-mode reminders
* additionally enforce the 916 port-timezone window, which lives in the
* caller below.
*/
export function isReminderDue(args: ReminderEligibilityArgs): boolean {
const now = args.now ?? new Date();
if (!['sent', 'partially_signed'].includes(args.status)) return false;
if (args.documensoId == null) return false;
if (args.remindersDisabled) return false;
const cadence = args.reminderCadenceOverride ?? args.templateCadenceDays;
if (cadence === null) return false;
if (args.lastReminderAt == null) return true;
const elapsedMs = now.getTime() - args.lastReminderAt.getTime();
return elapsedMs >= cadence * 24 * 60 * 60 * 1000;
}
// ─── sendReminderIfAllowed ───────────────────────────────────────────────────
export interface SendReminderOptions {
/** true = cron auto-fire, enforces 9-16 window + cadence cooldown.
* false (default) = manual UI action, bypasses both. */
auto?: boolean;
/** Optional — target a specific pending signer (parallel mode), or
* bypass the lowest-pending default in sequential mode (must still be the
* next pending signer in that case). */
signerId?: string;
}
export interface SendReminderResult {
sent: boolean;
reason?: string;
signerId?: string;
}
export async function sendReminderIfAllowed(
documentId: string,
portId: string,
options: SendReminderOptions = {},
): Promise<SendReminderResult> {
const { auto = false, signerId } = options;
const doc = await db.query.documents.findFirst({
where: and(eq(documents.id, documentId), eq(documents.portId, portId)),
});
if (!doc) return { sent: false, reason: 'Document not found' };
if (!doc.documensoId) return { sent: false, reason: 'Document has no Documenso id' };
if (!['sent', 'partially_signed'].includes(doc.status)) {
return { sent: false, reason: `Document is ${doc.status}` };
}
if (doc.remindersDisabled) {
return { sent: false, reason: 'Reminders disabled for this document' };
}
// Resolve effective cadence (override → template → null/disabled)
let templateCadenceDays: number | null = null;
if (auto) {
// Auto path needs a cadence to fire at all; manual sends bypass cadence.
// We still load the template cadence so `isReminderDue` has the input.
const templateRow = doc.fileId
? await db.query.documentTemplates.findFirst({
// Fallback: look up by document type if no explicit template_id.
where: and(
eq(documentTemplates.portId, portId),
eq(documentTemplates.templateType, doc.documentType),
),
})
: null;
templateCadenceDays = templateRow?.reminderCadenceDays ?? null;
const lastReminder = await db.query.documentEvents.findFirst({
where: and(
eq(documentEvents.documentId, documentId),
eq(documentEvents.eventType, 'reminder_sent'),
),
orderBy: (de, { desc }) => [desc(de.createdAt)],
});
const due = isReminderDue({
status: doc.status,
documensoId: doc.documensoId,
remindersDisabled: doc.remindersDisabled,
reminderCadenceOverride: doc.reminderCadenceOverride,
templateCadenceDays,
lastReminderAt: lastReminder?.createdAt ?? null,
});
if (!due) return { sent: false, reason: 'Cadence cooldown' };
// Auto only: 9-16 window in port timezone
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
const timezone = port?.timezone ?? 'UTC';
const currentHour = getCurrentHourInTimezone(timezone);
if (currentHour < 9 || currentHour >= 16) {
return { sent: false, reason: 'Outside 9-16 port-timezone window' };
}
}
// Pick the signer to nudge.
const pendingSigners = await db
.select()
.from(documentSigners)
.where(and(eq(documentSigners.documentId, documentId), eq(documentSigners.status, 'pending')))
.orderBy(sql`${documentSigners.signingOrder} ASC`);
if (pendingSigners.length === 0) {
return { sent: false, reason: 'No pending signers' };
}
let target = pendingSigners[0]!;
if (signerId) {
const requested = pendingSigners.find((s) => s.id === signerId);
if (!requested) {
return { sent: false, reason: 'Signer is not pending' };
}
// Sequential mode: require the lowest pending order.
if (requested.signingOrder !== pendingSigners[0]!.signingOrder) {
return { sent: false, reason: 'Signer is not next in sequence' };
}
target = requested;
}
try {
await documensoRemind(doc.documensoId, target.id, portId);
} catch (err) {
logger.error({ err, documentId, signerId: target.id }, 'Documenso reminder failed');
return { sent: false, reason: 'Documenso reminder failed' };
}
await db.insert(documentEvents).values({
documentId,
eventType: 'reminder_sent',
signerId: target.id,
eventData: {
signerEmail: target.signerEmail,
signerRole: target.signerRole,
auto,
},
});
return { sent: true, signerId: target.id };
}
// ─── processReminderQueue ────────────────────────────────────────────────────
/**
* Cron entry point. Selects in-flight documents whose effective cadence
* (override or template) is set, then attempts auto-fire on each.
* `interests.reminderEnabled` is no longer part of the gating — per-doc
* `remindersDisabled` is the kill switch instead.
*/
export async function processReminderQueue(portId: string): Promise<void> {
const activeDocs = await db
.select({ id: documents.id })
.from(documents)
.leftJoin(documentTemplates, eq(documentTemplates.templateType, documents.documentType))
.where(
and(
eq(documents.portId, portId),
inArray(documents.status, ['sent', 'partially_signed']),
isNotNull(documents.documensoId),
eq(documents.remindersDisabled, false),
sql`COALESCE(${documents.reminderCadenceOverride}, ${documentTemplates.reminderCadenceDays}) IS NOT NULL`,
),
);
for (const doc of activeDocs) {
try {
await sendReminderIfAllowed(doc.id, portId, { auto: true });
} catch (err) {
logger.error({ err, documentId: doc.id, portId }, 'Reminder processing failed');
}
}
}