import { and, eq, inArray } from 'drizzle-orm'; import { db } from '@/lib/db'; import { documents, documentSigners, documentEvents } from '@/lib/db/schema/documents'; import { interests } from '@/lib/db/schema/interests'; import { ports } from '@/lib/db/schema/ports'; import { sendReminder as documensoRemind } from '@/lib/services/documenso-client'; import { logger } from '@/lib/logger'; // BR-023: Reminders only during 9-16 in port timezone, with 24h cooldown 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); } export async function sendReminderIfAllowed( documentId: string, portId: string, ): Promise { const doc = await db.query.documents.findFirst({ where: and(eq(documents.id, documentId), eq(documents.portId, portId)), }); if (!doc || !doc.interestId || !doc.documensoId) return false; if (!['sent', 'partially_signed'].includes(doc.status)) return false; // Check interest.reminderEnabled const interest = await db.query.interests.findFirst({ where: eq(interests.id, doc.interestId), }); if (!interest?.reminderEnabled) return false; // Check 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 false; // Check 24h cooldown — last reminder_sent event for this document const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); const lastReminder = await db.query.documentEvents.findFirst({ where: and( eq(documentEvents.documentId, documentId), eq(documentEvents.eventType, 'reminder_sent'), ), orderBy: (de, { desc }) => [desc(de.createdAt)], }); if (lastReminder && lastReminder.createdAt > twentyFourHoursAgo) { return false; } // Find current pending signer (lowest signingOrder with status='pending') const pendingSigner = await db.query.documentSigners.findFirst({ where: and( eq(documentSigners.documentId, documentId), eq(documentSigners.status, 'pending'), ), orderBy: (ds, { asc }) => [asc(ds.signingOrder)], }); if (!pendingSigner) return false; // Send reminder via Documenso try { await documensoRemind(doc.documensoId, pendingSigner.id); } catch (err) { logger.error({ err, documentId, signerId: pendingSigner.id }, 'Failed to send Documenso reminder'); return false; } // Record event await db.insert(documentEvents).values({ documentId, eventType: 'reminder_sent', signerId: pendingSigner.id, eventData: { signerEmail: pendingSigner.signerEmail, signerRole: pendingSigner.signerRole }, }); return true; } export async function processReminderQueue(portId: string): Promise { // Find all documents with status 'sent' or 'partially_signed' linked to interests with reminderEnabled=true const activeInterests = await db.query.interests.findMany({ where: and( eq(interests.portId, portId), eq(interests.reminderEnabled, true), ), }); if (activeInterests.length === 0) return; const interestIds = activeInterests.map((i) => i.id); const activeDocs = await db.query.documents.findMany({ where: and( eq(documents.portId, portId), inArray(documents.status, ['sent', 'partially_signed']), inArray(documents.interestId, interestIds), ), }); for (const doc of activeDocs) { try { await sendReminderIfAllowed(doc.id, portId); } catch (err) { logger.error({ err, documentId: doc.id, portId }, 'Reminder processing failed for document'); } } }