Files
pn-new-crm/src/lib/services/document-reminders.ts
Matt 221ae5784e chore(autonomous-session): consolidate uncommitted work from prior session
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
2026-05-23 00:52:59 +02:00

327 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
*
* 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');
}
}
}