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. */ export async function processReminderQueue(portId: string): Promise { 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'); } } }