Files
pn-new-crm/src/lib/services/document-reminders.ts

124 lines
3.8 KiB
TypeScript
Raw Normal View History

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<boolean> {
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<void> {
// 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');
}
}
}