isReminderDue now keys off doc.remindersDisabled and the effective cadence (per-doc override → template default), dropping the implicit interests.reminderEnabled gate so non-EOI docs auto-remind correctly. sendReminderIfAllowed gains an options bag — auto:true keeps the 9-16 window + cadence cooldown for the cron, auto:false bypasses both for manual UI sends. signerId targets a specific pending signer (must be next in sequential mode). 7 unit tests cover the cadence math. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
212 lines
7.4 KiB
TypeScript
212 lines
7.4 KiB
TypeScript
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<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');
|
||
}
|
||
}
|
||
}
|