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