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

212 lines
7.4 KiB
TypeScript
Raw Normal View History

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');
}
}
}