Skip to content
Merged
33 changes: 33 additions & 0 deletions migrations/0174_rfp_pending_sla_email_receipts.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
-- 0174_rfp_pending_sla_email_receipts.sql
--
-- Exactly-once ledger for the Pending RFP 24h SLA breach email (worker/src/jobs/rfp-pending-sla.ts).
--
-- WHY: an RFP that stays in the "Pending RFP" bucket (stage=opportunity, not bid-board-owned,
-- rfp_approval_status in pending_outbox/pending) longer than 24h should alert leadership. Unlike the
-- decline email (0148), an SLA breach is TIME-based, not a status transition, so no DB trigger fires it —
-- the worker runs an hourly cron scan instead. Scanning hourly means the same breach is seen many times, so
-- this ledger is the exactly-once guard: the scan INSERTs a claim row ON CONFLICT DO NOTHING BEFORE sending,
-- and a send failure DELETEs the claim so the next scan retries.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
--
-- CYCLE IDENTITY: keyed (tenant_schema, deal_id, rfp_approval_requested_at). rfp_approval_requested_at is
-- the instant the RFP entered pending; a re-triggered RFP gets a NEW requested_at -> a fresh cycle -> a new
-- alert (correct). This is a PUBLIC table (one row per office+deal+cycle), same shape as 0148's
-- rfp_rejection_email_receipts; no per-tenant copy is needed.
--
-- This ships with the WORKER (the hourly job) and is gated behind RFP_PENDING_SLA_ENABLED so it merges inert.

CREATE TABLE IF NOT EXISTS public.rfp_pending_sla_email_receipts (
tenant_schema text NOT NULL,
deal_id uuid NOT NULL,
rfp_approval_requested_at timestamptz NOT NULL,
deal_number text,
recipient_emails text,
resend_message_id text,
sent_at timestamptz NOT NULL DEFAULT NOW(),
created_at timestamptz NOT NULL DEFAULT NOW(),
updated_at timestamptz NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_schema, deal_id, rfp_approval_requested_at)
);

CREATE INDEX IF NOT EXISTS rfp_pending_sla_email_receipts_deal_idx
ON public.rfp_pending_sla_email_receipts (tenant_schema, deal_id);
14 changes: 14 additions & 0 deletions worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { runAiDisconnectDigest } from "./jobs/ai-disconnect-digest.js";
import { runAiDisconnectEscalationScan } from "./jobs/ai-disconnect-escalation.js";
import { runAiDisconnectAdminTaskGeneration } from "./jobs/ai-disconnect-admin-tasks.js";
import { runAiInterventionManagerAlerts } from "./jobs/ai-intervention-manager-alerts.js";
import { runRfpPendingSlaScan } from "./jobs/rfp-pending-sla.js";
import { runCallRecordingCleanup } from "./jobs/call-recording-cleanup.js";
import { runCallRecordingTranscription } from "./jobs/call-recording-transcribe.js";
import { runRfpRequestDeadLetterSweep } from "./jobs/rfp-request-delivery.js";
Expand Down Expand Up @@ -268,6 +269,19 @@ async function main() {
}, { timezone: "America/Chicago" });
console.log("[Worker] Cron scheduled: rep performance rollup at 4:00 AM CT daily");

// Pending RFP 24h SLA: hourly scan that emails leadership once per pending cycle when an RFP has been
// awaiting approval > 24h. Inert until RFP_PENDING_SLA_ENABLED=true (the job self-gates); a receipts
// ledger keeps it exactly-once, so scanning hourly never double-sends.
cron.schedule("0 * * * *", async () => {
console.log("[Worker:cron] Running pending RFP SLA scan...");
try {
await runRfpPendingSlaScan();
} catch (err) {
console.error("[Worker:cron] Pending RFP SLA scan failed:", err);
}
}, { timezone: "America/Chicago" });
console.log("[Worker] Cron scheduled: pending RFP SLA scan hourly");

// Manager alerts: evaluate every 5 minutes and let office-local due gating decide when to send.
cron.schedule("*/5 * * * *", async () => {
console.log("[Worker:cron] Running intervention manager alerts...");
Expand Down
Loading
Loading