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 9–16 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 { 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. * * Performance: the pre-bulk version called `sendReminderIfAllowed` per * doc, which re-fetched the port row (invariant), the template-by-type * map (repeats heavily), the last reminder event, and the pending * signers — 5×N round trips per cron tick. This implementation hoists * the invariants out of the loop and turns the per-row queries into * grouped scans (one per dimension), so a port with 500 in-flight docs * is now ~7 round trips total instead of ~2500. */ export async function processReminderQueue(portId: string): Promise { const activeDocs = await db .select({ id: documents.id, documentType: documents.documentType, documensoId: documents.documensoId, status: documents.status, remindersDisabled: documents.remindersDisabled, reminderCadenceOverride: documents.reminderCadenceOverride, fileId: documents.fileId, }) .from(documents) // CRITICAL: scope the join to the same port — `documentTemplates.templateType` // is not unique across ports, so a leftJoin without `portId` produces a // cartesian explosion (one output row per template-type match across // every port). The downstream loop fires `documensoRemind` per row, // which means the same signer in port A would receive N reminders on // a single cron tick (once per port that defined a template of the // same type). Audit follow-up after Tier 3 ship. .leftJoin( documentTemplates, and( eq(documentTemplates.templateType, documents.documentType), eq(documentTemplates.portId, portId), ), ) .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`, ), ); if (activeDocs.length === 0) return; // Hoist invariants out of the per-doc loop ──────────────────────────────── // (1) Port row (timezone) — invariant across the whole batch. 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) { // Outside the 9-16 window — nothing to do this tick. return; } // (2) Per-type template cadence map — repeats per documentType. const distinctTypes = Array.from(new Set(activeDocs.map((d) => d.documentType))); const templateRows = await db .select({ templateType: documentTemplates.templateType, reminderCadenceDays: documentTemplates.reminderCadenceDays, }) .from(documentTemplates) .where( and( eq(documentTemplates.portId, portId), inArray(documentTemplates.templateType, distinctTypes), ), ); const templateCadenceByType = new Map( templateRows.map((r) => [r.templateType, r.reminderCadenceDays ?? null]), ); // (3) Latest reminder_sent event per doc — one grouped query. const docIds = activeDocs.map((d) => d.id); const lastReminderRows = await db .select({ documentId: documentEvents.documentId, lastAt: sql`max(${documentEvents.createdAt})`, }) .from(documentEvents) .where( and( inArray(documentEvents.documentId, docIds), eq(documentEvents.eventType, 'reminder_sent'), ), ) .groupBy(documentEvents.documentId); const lastReminderByDoc = new Map(lastReminderRows.map((r) => [r.documentId, r.lastAt])); // (4) Pending signers per doc — one inArray scan. const pendingSignerRows = await db .select() .from(documentSigners) .where(and(inArray(documentSigners.documentId, docIds), eq(documentSigners.status, 'pending'))) .orderBy(sql`${documentSigners.signingOrder} ASC`); const pendingByDoc = new Map(); for (const row of pendingSignerRows) { const arr = pendingByDoc.get(row.documentId) ?? []; arr.push(row); pendingByDoc.set(row.documentId, arr); } // Per-doc fire — at this point every per-row query is a Map.get. for (const doc of activeDocs) { try { const due = isReminderDue({ status: doc.status, documensoId: doc.documensoId, remindersDisabled: doc.remindersDisabled, reminderCadenceOverride: doc.reminderCadenceOverride, templateCadenceDays: templateCadenceByType.get(doc.documentType) ?? null, lastReminderAt: lastReminderByDoc.get(doc.id) ?? null, }); if (!due) continue; const pending = pendingByDoc.get(doc.id) ?? []; const target = pending[0]; if (!target || !doc.documensoId) continue; await documensoRemind(doc.documensoId, target.id, portId); await db.insert(documentEvents).values({ documentId: doc.id, eventType: 'reminder_sent', signerId: target.id, eventData: { signerEmail: target.signerEmail, signerRole: target.signerRole, auto: true, }, }); } catch (err) { logger.error({ err, documentId: doc.id, portId }, 'Reminder processing failed'); } } }