Bundles the prior autonomous-session output that was sitting unstaged: - Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances) - country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk after the per-subpath dynamic-import approach silently failed in webpack) - Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index, redirects (ocr to ai, reports to dashboard, invitations to users), docs/admin-ia-proposal.md - Per-template email tester (registry + endpoint + UI on Email admin page) - Cancel-document mode picker (delete-from-Documenso vs keep-for-audit) - Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers - Customize-widgets per-region sortables at xl+ (charts/rails/feed); single flat sortable below xl when the layout stacks; per-viewport saved orders - Audit doc updates capturing each shipped item - Lint fixes: react-compiler immutability in DonutChart (reduce instead of let-reassign), set-state-in-effect disables in CountryFlag and UploadForSigning preview-bytes effect, unused 'confirm' destructures in interest contract + reservation tabs, unescaped apostrophe in test-template card copy
327 lines
12 KiB
TypeScript
327 lines
12 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.
|
||
*
|
||
* Performance: the pre-bulk version called `sendReminderIfAllowed` per
|
||
* doc, which re-fetched the port row (invariant), the template-by-type
|
||
* map (repeats heavily), the last reminder event, and the pending
|
||
* signers - 5×N round trips per cron tick. This implementation hoists
|
||
* the invariants out of the loop and turns the per-row queries into
|
||
* grouped scans (one per dimension), so a port with 500 in-flight docs
|
||
* is now ~7 round trips total instead of ~2500.
|
||
*/
|
||
export async function processReminderQueue(portId: string): Promise<void> {
|
||
const activeDocs = await db
|
||
.select({
|
||
id: documents.id,
|
||
documentType: documents.documentType,
|
||
documensoId: documents.documensoId,
|
||
status: documents.status,
|
||
remindersDisabled: documents.remindersDisabled,
|
||
reminderCadenceOverride: documents.reminderCadenceOverride,
|
||
fileId: documents.fileId,
|
||
})
|
||
.from(documents)
|
||
// CRITICAL: scope the join to the same port - `documentTemplates.templateType`
|
||
// is not unique across ports, so a leftJoin without `portId` produces a
|
||
// cartesian explosion (one output row per template-type match across
|
||
// every port). The downstream loop fires `documensoRemind` per row,
|
||
// which means the same signer in port A would receive N reminders on
|
||
// a single cron tick (once per port that defined a template of the
|
||
// same type). Audit follow-up after Tier 3 ship.
|
||
.leftJoin(
|
||
documentTemplates,
|
||
and(
|
||
eq(documentTemplates.templateType, documents.documentType),
|
||
eq(documentTemplates.portId, portId),
|
||
),
|
||
)
|
||
.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`,
|
||
),
|
||
);
|
||
|
||
if (activeDocs.length === 0) return;
|
||
|
||
// Hoist invariants out of the per-doc loop ────────────────────────────────
|
||
|
||
// (1) Port row (timezone) - invariant across the whole batch.
|
||
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) {
|
||
// Outside the 9-16 window - nothing to do this tick.
|
||
return;
|
||
}
|
||
|
||
// (2) Per-type template cadence map - repeats per documentType.
|
||
const distinctTypes = Array.from(new Set(activeDocs.map((d) => d.documentType)));
|
||
const templateRows = await db
|
||
.select({
|
||
templateType: documentTemplates.templateType,
|
||
reminderCadenceDays: documentTemplates.reminderCadenceDays,
|
||
})
|
||
.from(documentTemplates)
|
||
.where(
|
||
and(
|
||
eq(documentTemplates.portId, portId),
|
||
inArray(documentTemplates.templateType, distinctTypes),
|
||
),
|
||
);
|
||
const templateCadenceByType = new Map(
|
||
templateRows.map((r) => [r.templateType, r.reminderCadenceDays ?? null]),
|
||
);
|
||
|
||
// (3) Latest reminder_sent event per doc - one grouped query.
|
||
const docIds = activeDocs.map((d) => d.id);
|
||
const lastReminderRows = await db
|
||
.select({
|
||
documentId: documentEvents.documentId,
|
||
lastAt: sql<Date>`max(${documentEvents.createdAt})`,
|
||
})
|
||
.from(documentEvents)
|
||
.where(
|
||
and(
|
||
inArray(documentEvents.documentId, docIds),
|
||
eq(documentEvents.eventType, 'reminder_sent'),
|
||
),
|
||
)
|
||
.groupBy(documentEvents.documentId);
|
||
const lastReminderByDoc = new Map(lastReminderRows.map((r) => [r.documentId, r.lastAt]));
|
||
|
||
// (4) Pending signers per doc - one inArray scan.
|
||
const pendingSignerRows = await db
|
||
.select()
|
||
.from(documentSigners)
|
||
.where(and(inArray(documentSigners.documentId, docIds), eq(documentSigners.status, 'pending')))
|
||
.orderBy(sql`${documentSigners.signingOrder} ASC`);
|
||
const pendingByDoc = new Map<string, typeof pendingSignerRows>();
|
||
for (const row of pendingSignerRows) {
|
||
const arr = pendingByDoc.get(row.documentId) ?? [];
|
||
arr.push(row);
|
||
pendingByDoc.set(row.documentId, arr);
|
||
}
|
||
|
||
// Per-doc fire - at this point every per-row query is a Map.get.
|
||
for (const doc of activeDocs) {
|
||
try {
|
||
const due = isReminderDue({
|
||
status: doc.status,
|
||
documensoId: doc.documensoId,
|
||
remindersDisabled: doc.remindersDisabled,
|
||
reminderCadenceOverride: doc.reminderCadenceOverride,
|
||
templateCadenceDays: templateCadenceByType.get(doc.documentType) ?? null,
|
||
lastReminderAt: lastReminderByDoc.get(doc.id) ?? null,
|
||
});
|
||
if (!due) continue;
|
||
|
||
const pending = pendingByDoc.get(doc.id) ?? [];
|
||
const target = pending[0];
|
||
if (!target || !doc.documensoId) continue;
|
||
|
||
await documensoRemind(doc.documensoId, target.id, portId);
|
||
await db.insert(documentEvents).values({
|
||
documentId: doc.id,
|
||
eventType: 'reminder_sent',
|
||
signerId: target.id,
|
||
eventData: {
|
||
signerEmail: target.signerEmail,
|
||
signerRole: target.signerRole,
|
||
auto: true,
|
||
},
|
||
});
|
||
} catch (err) {
|
||
logger.error({ err, documentId: doc.id, portId }, 'Reminder processing failed');
|
||
}
|
||
}
|
||
}
|