Files
pn-new-crm/src/lib/services/document-reminders.ts
Matt Ciaccio 63c4073e64 fix(audit-verification): regressions found in post-Tier-6 review
Two parallel reviews of the Tier 0–6 work surfaced one CRITICAL
regression and a handful of remaining cross-tenant gaps that the
original audit didn't enumerate. All fixed here:

CRITICAL
* document-reminders.processReminderQueue — the new bulk-fetch
  leftJoin to documentTemplates was scoped on `templateType` alone.
  Templates of the same type exist in every port; the cartesian
  explosion would have fired one Documenso reminder PER matching
  template-row per cron tick (a 5-port deploy = 5 reminders to the
  same signer per cycle). Added eq(documentTemplates.portId, portId)
  to the join.
* All five remaining Documenso webhook handlers (RecipientSigned /
  Completed / Opened / Rejected / Cancelled) accept and require an
  optional portId now, with a shared resolveWebhookDocument() helper
  that refuses to mutate when the lookup is ambiguous across tenants
  without a resolved port. Tier 5's port-scoping was applied only to
  Expired; the route now forwards the matched portId to every
  handler. Tightens the WHERE clauses on subsequent UPDATEs to (id,
  portId) for defense-in-depth.

HIGH
* verifyDocumensoSecret rejects when `expected` is empty —
  timingSafeEqual(0-bytes, 0-bytes) was returning true, so a dev env
  with a blank DOCUMENSO_WEBHOOK_SECRET would accept a request whose
  X-Documenso-Secret header was also missing/empty.
  listDocumensoWebhookSecrets skips the env entry when blank.
* /api/public/health — the website-intake-secret comparison was a
  string `===` (not constant-time). Switched to timingSafeEqual via
  Buffer.from().

MEDIUM
* server.ts SIGTERM ordering — Socket.io closes BEFORE the HTTP
  drain so long-poll websockets stop holding the server open past
  the compose stop_grace_period.
* /api/v1/me PATCH preferences merge — allow-list filter on the
  merged JSONB so legacy rows from the old .passthrough() era stop
  silently re-shipping their bloat to disk.

Migration fixes (deploy-blocking)
* 0041 referenced `port_role_overrides.permissions` (column is
  `permission_overrides`) — overrides are partial JSONB and don't
  need backfilling at all (deepMerge resolves edit from the base
  role). Removed the override UPDATEs entirely.
* 0042 switched all FK + CHECK adds to NOT VALID + VALIDATE so the
  brief table-lock phase is decoupled from the row-scan validation,
  giving a cleaner abort-and-restart story if a constraint catches
  dirty production data. Added a pre-cleanup UPDATE for
  invoices.billing_entity_id = '' rows (backfills from clientName,
  falls back to the row id) so the new non-empty CHECK passes on a
  dirty table.

Test status: 1175/1175 vitest, tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:19:39 +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');
}
}
}