Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 | 13x 4x 3x 3x 3x 3x 40x 40x 40x 8x 20x 20x 6x 3x 1x 2x 15x 14x 28x 75x 75x 75x 26x 26x 1x 25x 25x 1x 24x 2x 22x 29x 29x 49x 49x 49x 49x 1x 48x 3x 45x 5x 40x 40x 40x 49x 3x 2x 1x 38x 28x 28x 2x 36x 8x 28x 2x 26x 34x 26x 26x 4x 4x 22x 22x 22x 26x 6x 4x 2x 26x 3x 23x 2x 21x 1x 20x 20x 20x 20x 20x 49x 49x 2x 2x 18x 49x 18x 18x 18x 18x | /**
* The ONLY send path for the RRM ladder (F-21 Phase 1). See
* docs/operations/rrm-ladder-design.md §5 for the full design; this module
* implements it verbatim.
*
* Every check below runs in a FIXED order and is never reordered:
*
* 1. prospect exists
* 2. global suppression (permanent, survives an erased prospect)
* 3. per-prospect do-not-contact
* 4. kill switch — but ONLY for a send that would actually be a template;
* a free-form reply into an already-open window is exactly what the
* halt is meant to keep working (docs §5, decision L1: the halt is the
* only thing standing between a bad send and a login-OTP outage, and it
* would be self-defeating to also block the operator/engine from
* replying to someone who just wrote back)
* 5. the 24h customer-service window: open -> free-form, unconditionally
* (even for a caller that asked for `intent: "template"` — the ladder
* never actually hits this corner because a reply already cancels the
* run before it re-reaches the gateway, but the contract stays correct
* for any future caller); closed -> `freeform_only` refuses, anything
* else sends a template
* 6. frequency caps — template sends only, counted on the PROSPECT's own
* phone (never the send-time recipient — see `resolveSendRecipient`)
* 7. the IST send window / holiday calendar — template sends only; a
* free-form reply is never quiet-hours-blocked
* 8. WhatsApp not configured
* 9. the actual send
*
* Steps 6 and 7 are template-only per the design doc, and step 4's "would
* this be a template" question can only be answered once step 5's window
* state is known — so `windowOpen` is computed once, up front, as a
* side-effect-free READ, and steps 4/5/6/7 all consult that one value. This
* does not reorder anything a caller can observe: the decision precedence
* (suppressed > dnc > no_consent > halted > window_closed > frequency >
* quiet_hours > not_configured) is exactly the order above.
*
* Every `ok:false` return appends a `send_blocked` event first — including
* `prospect_not_found`, using the caller's raw `prospectId` even though no
* row exists for it (`rrm_prospect_events` deliberately has no FK, precisely
* so an event can outlive — or in this one case, never have had — a prospect
* row). Silent drops are forbidden by design; this makes that unconditional.
*/
import { and, eq, gte, isNotNull, sql } from "drizzle-orm";
import type { DrizzleD1Database } from "drizzle-orm/d1";
import { createDal, type Dal } from "../../dal";
import { RRM_CONFIG_DEFAULTS, RRM_CONFIG_KEYS } from "../../dal/rrm/config.dal";
import type { RrmActorContext } from "../../dal/rrm/prospects.dal";
import type { WaMessage } from "../../db/schema";
import * as schema from "../../db/schema";
import { resolveEnvironment } from "../../lib/domain-utils";
import { getWhatsAppSafetyConfig } from "../../lib/env-config";
import { canSendBusinessInitiated } from "../../lib/rrm/consent";
import { isWithinSendWindow } from "../../lib/rrm/ist";
import {
isWhatsAppSendable,
requireWhatsAppClient,
} from "../../lib/whatsapp/client";
import type { WhatsAppTemplateComponent } from "../../lib/whatsapp/types";
import { ConversationService } from "../whatsapp/conversation.service";
export type RrmSendIntent =
| "template"
| "freeform_or_template"
| "freeform_only";
export type RrmSendInput = {
prospectId: string;
intent: RrmSendIntent;
/** Required for a send that ends up a template (see the window rule above). */
templateName?: string;
/** Positional `{{1}}, {{2}}` … values for the template's BODY component. */
variables?: string[];
/** Required for a send that ends up free-form. */
text?: string;
stepKey?: string;
runId?: string;
actor: { type: "system" | "operator"; id?: string };
/**
* Force past gates that are OUR policy, when an operator has decided the
* campaign is worth the risk. Every forced send is recorded on the
* `message_sent` event, so "who sent this outside the window, and why" has
* an answer months later.
*
* Requires an operator actor — an automated sequence must never override
* itself, or the gates would exist only until the first scheduler bug.
*/
override?: { gates: RrmOverridableGate[]; reason: string };
};
/**
* The gates an operator may force.
*
* Deliberately a SUBSET of `RrmSendDecision`, not the whole union. The three
* missing from it are missing on purpose:
*
* - `suppressed` / `dnc` — somebody told us to stop. Under DPDP that is
* binding, and messaging opt-outs is the fastest way to lose the number
* that also serves `interioring_auth_otp` (see apps/go/CLAUDE.md). Because
* they are absent from this type, no caller can even NAME them: forcing an
* opt-out is a compile error, not a code review.
* - `no_consent` — Lane E (`POST /prospects/outreach-basis`) is the recorded
* way past this one. A second, unrecorded path would mean the recorded one
* gets skipped.
* - `not_configured` / `error` / `window_closed` — not policy. No token is no
* token, a missing template name is a caller bug, and a closed window only
* ever redirects a send to the template path anyway.
*/
export type RrmOverridableGate = "halted" | "frequency" | "quiet_hours";
/**
* Is this gate being forced past on this send?
*
* `system` actors are refused regardless of what they pass: the override is an
* operator's decision, and a scheduled step is not an operator.
*/
export function isOverridden(
input: RrmSendInput,
gate: RrmOverridableGate,
): boolean {
if (input.actor.type !== "operator") return false;
return input.override?.gates.includes(gate) ?? false;
}
export type RrmSendDecision =
| "suppressed"
| "dnc"
/**
* No lawful basis to START a conversation with this person (FR / DPDP).
* Template sends only — see the gate in `sendToProspect`.
*/
| "no_consent"
| "halted"
| "window_closed"
| "frequency"
| "quiet_hours"
| "not_configured"
| "error";
export type RrmSendResult =
| { ok: true; wamid: string | null; kind: "template" | "freeform" }
| { ok: false; decision: RrmSendDecision; detail?: string };
export type RrmGatewayContext = {
db: DrizzleD1Database<typeof schema>;
env: CloudflareBindings;
/** Injectable clock. Defaults to now. */
now?: Date;
};
const WINDOW_MS = 24 * 60 * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
/** Spec §5/L5: 4 templates per rolling 7 days, 4 templates ever. */
const SEVEN_DAY_CAP = 4;
const LIFETIME_CAP = 4;
/**
* The prospect's own conversation, matched the same way the RRM inbox route
* matches it (`routes/admin/rrm/inbox.routes.ts`, both the SQL join in
* `GET /inbox` and the two-lookup form in `POST /messages/send`): Meta
* delivers `from` as bare digits, but an outbound-first conversation may have
* been created with a leading `+`, so both spellings are tried.
*/
async function findProspectConversation(dal: Dal, phoneNorm: string) {
return (
(await dal.waConversations.findByPhoneNumber(phoneNorm)) ??
(await dal.waConversations.findByPhoneNumber(`+${phoneNorm}`))
);
}
/** Is the 24h customer-service window open right now, per the prospect's own conversation? */
async function isCustomerWindowOpen(
dal: Dal,
phoneNorm: string,
now: Date,
): Promise<boolean> {
const conversation = await findProspectConversation(dal, phoneNorm);
if (!conversation?.lastCustomerMessageAt) return false;
return (
now.getTime() - conversation.lastCustomerMessageAt.getTime() < WINDOW_MS
);
}
/**
* Non-prod safety redirect, copied from
* `lib/communication/adapters/whatsapp.adapter.ts`'s `applySafetyGuard`
* (the mechanism the campaign/bulk-sender paths use) rather than imported
* from it: that file is a different subsystem this module does not own, and
* duplicating six lines here keeps the gateway self-contained and
* independently testable. Behaviour is identical: production never
* redirects; non-prod redirects anything not on the allow-list to the
* override number.
*
* Applied ONLY to the actual send recipient — never to the frequency/lifetime
* cap counting (`countTemplateSends` below), which keys on `prospect_id` and
* would otherwise put every non-prod test prospect in one shared bucket.
*/
function resolveSendRecipient(
phoneNorm: string,
env: CloudflareBindings,
): string {
const { overrideNumber, allowedNumbers } = getWhatsAppSafetyConfig(
resolveEnvironment(env.ENVIRONMENT),
);
if (!overrideNumber) return phoneNorm;
const normalizedAllowed = allowedNumbers.map((n) => n.replace(/\D/g, ""));
if (normalizedAllowed.includes(phoneNorm.replace(/\D/g, "")))
return phoneNorm;
return overrideNumber;
}
/** One BODY component with positional `{{n}}` text parameters, or none for a variable-free template. */
function buildTemplateComponents(
variables: string[] | undefined,
): WhatsAppTemplateComponent[] | undefined {
if (!variables || variables.length === 0) return undefined;
return [
{
type: "body",
parameters: variables.map((text) => ({ type: "text" as const, text })),
},
];
}
/**
* Counts template sends to this prospect — the three Meta caps (24h / 7d /
* lifetime) apply to business-INITIATED messages only.
*
* Three conditions, and the third is not redundant: `prospect_id` scopes it to
* this person, `step_key` marks it as a ladder send (never `type = 'template'`
* alone, which would also match a template another subsystem sent), and
* `template_name` is what separates an initiation from a free-form reply.
* Without that last one the N1 acknowledgement — free-form, inside an open
* window, costing nothing under Meta's rules but carrying a `step_key` — would
* burn one of the four lifetime template slots.
*/
async function countTemplateSends(
db: DrizzleD1Database<typeof schema>,
prospectId: string,
since?: Date,
): Promise<number> {
const conditions = [
eq(schema.waMessages.prospectId, prospectId),
eq(schema.waMessages.direction, "outbound"),
isNotNull(schema.waMessages.stepKey),
isNotNull(schema.waMessages.templateName),
];
if (since) conditions.push(gte(schema.waMessages.dateCreated, since));
const rows = await db
.select({ count: sql<number>`count(*)` })
.from(schema.waMessages)
.where(and(...conditions));
/* v8 ignore start -- defensive: `count(*)` with no GROUP BY always
* returns exactly one row, even when nothing matches (count 0), so
* `rows[0]` is never actually undefined. Guards against a future change
* to this query rather than a reachable branch today. */
return rows[0]?.count ?? 0;
/* v8 ignore stop */
}
/** Returns the limit that was hit, or `undefined` if all three caps have room. */
async function checkFrequencyCaps(
db: DrizzleD1Database<typeof schema>,
prospectId: string,
now: Date,
): Promise<string | undefined> {
const since24h = new Date(now.getTime() - DAY_MS);
if ((await countTemplateSends(db, prospectId, since24h)) > 0) {
return "24h_limit_reached";
}
const since7d = new Date(now.getTime() - 7 * DAY_MS);
if ((await countTemplateSends(db, prospectId, since7d)) >= SEVEN_DAY_CAP) {
return "7d_limit_reached";
}
if ((await countTemplateSends(db, prospectId)) >= LIFETIME_CAP) {
return "lifetime_limit_reached";
}
return undefined;
}
/**
* Every `ok:false` path funnels through here so the "always write
* `send_blocked`" rule cannot be forgotten at a call site. Keys the event on
* `input.prospectId` (the caller's raw string) rather than a fetched
* prospect row, so `prospect_not_found` — the one decision reached before any
* row is loaded — still gets its event.
*/
async function blocked(
dal: Dal,
input: RrmSendInput,
now: Date,
decision: RrmSendDecision,
detail?: string,
): Promise<RrmSendResult> {
await dal.rrmEvents.append({
prospectId: input.prospectId,
type: "send_blocked",
actorType: input.actor.type,
actorId: input.actor.id ?? null,
channel: "whatsapp",
payload: {
decision,
stepKey: input.stepKey ?? null,
templateName: input.templateName ?? null,
},
occurredAt: now,
});
return { ok: false, decision, detail };
}
export async function sendToProspect(
ctx: RrmGatewayContext,
input: RrmSendInput,
): Promise<RrmSendResult> {
const now = ctx.now ?? new Date();
const dal = createDal(ctx.db);
// 1. Prospect must exist.
const prospect = await dal.rrmProspects.findById(input.prospectId);
if (!prospect) {
return blocked(dal, input, now, "error", "prospect_not_found");
}
// 2. Global suppression — permanent, keyed on phone, survives erasure.
if (await dal.rrmSuppression.isSuppressed(prospect.phoneNorm)) {
return blocked(dal, input, now, "suppressed");
}
// 3. Per-prospect do-not-contact.
if (prospect.doNotContact) {
return blocked(dal, input, now, "dnc");
}
// Gates this send actually went THROUGH, not merely the ones the operator
// authorised. "Authorised quiet_hours" and "sent at 02:00" are different
// facts, and only the second one matters when somebody asks later.
const forced: RrmOverridableGate[] = [];
// A read, not a decision: needed both to answer step 4 ("would this send
// be a template") and to make step 5's own choice.
const windowOpen = await isCustomerWindowOpen(dal, prospect.phoneNorm, now);
const wouldBeTemplate = !windowOpen && input.intent !== "freeform_only";
// 4. Kill switch — templates only. A free-form reply into an open window
// continues during a halt; that is the point of the halt (see the module
// doc comment).
if (wouldBeTemplate && (await dal.rrmConfig.isHalted())) {
if (!isOverridden(input, "halted")) {
return blocked(dal, input, now, "halted");
}
forced.push("halted");
}
// 4b. Consent — TEMPLATE SENDS ONLY, and that restriction is the point.
//
// `canSendBusinessInitiated` refuses `none` and refuses NULL. It existed,
// fully tested, with THREE places in the repo asserting it was enforced
// (db/schema/rrm.ts, docs/operations/whatsapp-consent.md, and
// prospects.routes.ts) — and it had zero production call sites. A scraped
// list imported at `consent_basis = 'none'` was treated identically to
// someone who ticked a box on the partner page. This is that wiring.
//
// Deliberately NOT applied to a free-form reply: if someone messaged us,
// the window is open, and refusing to answer a person who wrote to you is
// worse than anything this gate protects against. The function's own name
// says business-INITIATED; an inbound already records `user_initiated`
// consent (inbound.service.ts), so an open window implies a basis anyway.
if (wouldBeTemplate) {
const consent = canSendBusinessInitiated(prospect);
if (!consent.allowed) {
return blocked(dal, input, now, "no_consent", consent.reason);
}
}
// 5. The 24h window decides the kind of send. Open always wins as
// free-form, regardless of the caller's declared intent; closed refuses
// outright for `freeform_only`, otherwise proceeds as a template.
let kind: "template" | "freeform";
if (windowOpen) {
kind = "freeform";
} else if (input.intent === "freeform_only") {
return blocked(dal, input, now, "window_closed");
} else {
kind = "template";
}
if (kind === "template") {
// 6. Frequency/lifetime caps, template sends only, counted on the
// prospect's own phone (see `resolveSendRecipient`).
const capDetail = await checkFrequencyCaps(ctx.db, prospect.id, now);
if (capDetail) {
Eif (!isOverridden(input, "frequency")) {
return blocked(dal, input, now, "frequency", capDetail);
}
forced.push("frequency");
}
// 7. IST send window / holiday calendar, template sends only.
const startHour = await dal.rrmConfig.getNumber(
RRM_CONFIG_KEYS.sendWindowStartHour,
RRM_CONFIG_DEFAULTS[RRM_CONFIG_KEYS.sendWindowStartHour],
);
const endHour = await dal.rrmConfig.getNumber(
RRM_CONFIG_KEYS.sendWindowEndHour,
RRM_CONFIG_DEFAULTS[RRM_CONFIG_KEYS.sendWindowEndHour],
);
// Absent key -> no holidays configured, not "block everything": see
// the "no safe default" reasoning on `RrmConfigDal.getJson`. Malformed
// JSON THROWS out of `getJson` and is allowed to propagate — a corrupt
// holiday list is a misconfiguration that must fail loudly, not a
// `send_blocked` decision.
const holidays =
(await dal.rrmConfig.getJson<string[]>(RRM_CONFIG_KEYS.holidays)) ?? [];
if (!isWithinSendWindow(now.getTime(), startHour, endHour, holidays)) {
if (!isOverridden(input, "quiet_hours")) {
return blocked(dal, input, now, "quiet_hours");
}
forced.push("quiet_hours");
}
}
// 8. WhatsApp must be configured. Checked directly (not via a
// try/catch around `requireWhatsAppClient`) so this stays a decision,
// never a throw.
// Asks whether a send CAN happen, not whether a token exists — locally the
// dry-run client stands in for one, so this path is reachable on a laptop
// instead of stopping here having proved nothing.
if (!isWhatsAppSendable(ctx.env)) {
return blocked(dal, input, now, "not_configured");
}
// Caller-contract validation for the kind we ended up with. These are
// `send_blocked` (caller error), not `message_failed` (send attempted and
// failed) — no network call has happened yet.
if (kind === "freeform" && !input.text?.trim()) {
return blocked(dal, input, now, "error", "text_required");
}
if (kind === "template" && !input.templateName) {
return blocked(dal, input, now, "error", "template_name_required");
}
const client = requireWhatsAppClient(ctx.env);
const conversationService = new ConversationService(dal);
const sendRecipient = resolveSendRecipient(prospect.phoneNorm, ctx.env);
const sendConversation = await conversationService.getOrCreateConversation(
sendRecipient,
prospect.name ?? prospect.firmName ?? undefined,
);
// `ConversationService.reply`/`replyWithTemplate` require a string
// `adminUserId` and store it verbatim as `wa_messages.sent_by`, an FK to
// `users.id`. There is no real user behind a system-triggered send, and
// `sent_by` must stay NULL — D1 enforces this FK, so a fabricated id would
// throw on insert. `addOutboundMessage` resolves `sentBy: options?.sentBy
// ?? null`, so passing `undefined` through yields the correct NULL; the
// cast only silences the compiler. This file cannot fix the root cause —
// `ConversationService`'s signature should accept `sentBy?: string | null`
// — see the final report.
// A system-triggered send has no user behind it, and `wa_messages.sent_by`
// is an FK to `users.id` — so the correct value is null, not a fake id.
const sentBy = input.actor.id ?? null;
// 9. Send. Only this call is wrapped: a Graph/network failure here is
// classified `message_failed` and RE-THROWN so the queue consumer decides
// retry policy; every write after this point is bookkeeping for a send
// that already succeeded, so it is allowed to throw normally rather than
// being misreported as a failed send.
let stored: WaMessage;
try {
stored =
kind === "freeform"
? await conversationService.reply(
sendConversation.id,
input.text as string,
sentBy,
client,
)
: await conversationService.replyWithTemplate(
sendConversation.id,
input.templateName as string,
"en",
buildTemplateComponents(input.variables),
sentBy,
client,
);
} catch (err) {
await dal.rrmEvents.append({
prospectId: prospect.id,
type: "message_failed",
actorType: input.actor.type,
actorId: input.actor.id ?? null,
channel: "whatsapp",
payload: {
kind,
stepKey: input.stepKey ?? null,
templateName: input.templateName ?? null,
},
occurredAt: now,
});
throw err;
}
const wamid = stored.wamid ?? null;
// Stamp the RRM identity onto the row `reply`/`replyWithTemplate` just
// wrote — they have no parameter for these three columns, so this is a
// deliberate follow-up write, not a workaround.
await ctx.db
.update(schema.waMessages)
.set({
prospectId: prospect.id,
stepKey: input.stepKey ?? null,
sequenceRunId: input.runId ?? null,
dateUpdated: now,
})
.where(eq(schema.waMessages.id, stored.id));
// Ids and enums only — never the phone or the message text (module doc,
// and the repo-wide rule this table's other writers already follow).
await dal.rrmEvents.append({
prospectId: prospect.id,
type: "message_sent",
actorType: input.actor.type,
actorId: input.actor.id ?? null,
channel: "whatsapp",
payload: {
kind,
stepKey: input.stepKey ?? null,
templateName: input.templateName ?? null,
wamid,
// Absent on an ordinary send, so a forced one is greppable.
...(forced.length
? { forced, forcedReason: input.override?.reason ?? null }
: {}),
},
occurredAt: now,
});
// Forward-only, and never past `contacted` here — `setStage` itself
// refuses to move a prospect backwards or past a terminal state, so
// calling this unconditionally on every successful send (not just the
// first) is safe: it is a no-op once the prospect is already further
// along.
await dal.rrmProspects.setStage(prospect.id, "contacted", {
actorType: input.actor.type,
actorId: input.actor.id ?? null,
channel: "whatsapp",
reason: input.stepKey ? `rrm_send:${input.stepKey}` : "rrm_gateway_send",
} satisfies RrmActorContext);
// `setStage`'s own `stageTimestamps` only bumps `last_contacted_at` on the
// ONE transition into `contacted` — a later send to an already-`contacted`
// (or further along) prospect would otherwise leave it stale, and the
// portal reads this field as "last contacted" recency. Written
// unconditionally, deterministically under `ctx.now`, on every send.
await ctx.db
.update(schema.rrmProspects)
.set({ lastContactedAt: now, dateUpdated: now })
.where(eq(schema.rrmProspects.id, prospect.id));
return { ok: true, wamid, kind };
}
|