All files / services/rrm delivery.service.ts

100% Statements 29/29
100% Branches 26/26
100% Functions 4/4
100% Lines 25/25

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                                                                    2x 2x                     2x                                                                                           17x                 15x 15x 12x                                         12x                   12x                           9x 5x 5x 2x                         18x 18x   18x 1x     17x 17x 15x   12x 1x                 11x           18x                           11x    
/**
 * Meta delivery statuses → the RRM funnel (F-21 Phase 1).
 *
 * The gateway records `message_sent` the moment Meta ACCEPTS a message. That
 * is not the same as the message ARRIVING: Meta returns 200 for a number that
 * is not on WhatsApp, for a handset that never comes online, and for a
 * recipient who has blocked the business. Those outcomes come back later, on
 * the status webhook, and until this module existed nothing consumed them —
 * `message_delivered` and `message_read` were in `RRM_EVENT_TYPES` and read by
 * `ramp.routes.ts`, but written by no one, so the ramp dashboard's
 * `totalDelivered` was a hard-coded-looking zero and the delivery half of the
 * ladder's safety story did not exist.
 *
 * Why that matters more here than on an ordinary campaign: the business runs
 * ONE WhatsApp number (owner decision, 2026-08-28) and it also serves login
 * OTPs. A number quietly failing to deliver is the early symptom of the
 * quality-rating slide that ends in an OTP outage. `scheduler.service`'s
 * auto-halt already watches for Meta's `368` / `131031` account-restriction
 * errors — but it only ever saw the ones raised synchronously at send time.
 * Meta reports most restrictions asynchronously, on this webhook. Feeding
 * them in is what arms that guard for the failure mode it was written for.
 *
 * SCOPE — this module records. It does not decide. No stage moves, no
 * suppression, no halting: those belong to the scheduler and the DAL that own
 * them. `bounce` exists in the suppression-reason enum and is deliberately
 * NOT set here; auto-suppressing on a single failed status would drop a
 * prospect whose phone was merely off, and that policy call is the owner's.
 */
 
import { and, eq, sql } from "drizzle-orm";
import type { DrizzleD1Database } from "drizzle-orm/d1";
import { RrmEventsDal } from "../../dal/rrm/events.dal";
import * as schema from "../../db/schema";
 
const EVENTS = schema.rrmProspectEvents;
const MESSAGES = schema.waMessages;
 
/**
 * Meta's status → the RRM event it completes.
 *
 * `sent` is deliberately absent. The gateway appends `message_sent` at the
 * moment of the send, and `ramp.routes.ts` builds `sent`,
 * `uniqueRecipients24h` and the reply-rate denominator from that type. Adding
 * a second `message_sent` here would silently double every one of them and
 * make the ramp gates read as twice the volume actually achieved.
 */
const STATUS_EVENT = {
	delivered: "message_delivered",
	read: "message_read",
	failed: "message_failed",
} as const;
 
export type RrmTrackedStatus = keyof typeof STATUS_EVENT;
 
export type RrmDeliveryInput = {
	/** Meta's message id. The ONLY thing that identifies this as ours. */
	wamid: string;
	status: "sent" | "delivered" | "read" | "failed";
	/** Meta's numeric error code, when `status = "failed"`. */
	errorCode?: number | string;
	/** Meta's short error title, when `status = "failed"`. */
	errorTitle?: string;
	/** Meta's `timestamp` (unix seconds, as a string) or a Date. Defaults to now. */
	occurredAt?: Date;
};
 
export type RrmDeliveryOutcome =
	/** Not an RRM message — an OTP, a homeowner notification, or unknown. */
	| { outcome: "not_rrm" }
	/** `sent`: the gateway already owns that event. */
	| { outcome: "ignored"; reason: "sent_owned_by_gateway" }
	/** Meta redelivered a status we have already recorded. */
	| { outcome: "duplicate"; prospectId: string }
	| { outcome: "recorded"; prospectId: string; type: string };
 
/**
 * Is this wamid one of OUR outbound RRM messages?
 *
 * The lookup is by wamid and nothing else. `phone_number_id` cannot be the
 * discriminator the way it is for inbound routing: with a single number, an
 * OTP status and a ladder status arrive on the same id. `wa_messages.wamid`
 * carries a unique index, and `prospect_id` is non-null only for rows the RRM
 * gateway wrote — so a row with both IS an RRM send, on any number.
 */
async function findRrmMessage(
	db: DrizzleD1Database<typeof schema>,
	wamid: string,
): Promise<{
	prospectId: string;
	stepKey: string | null;
	templateName: string | null;
} | null> {
	const rows = await db
		.select({
			prospectId: MESSAGES.prospectId,
			stepKey: MESSAGES.stepKey,
			templateName: MESSAGES.templateName,
		})
		.from(MESSAGES)
		.where(and(eq(MESSAGES.wamid, wamid), eq(MESSAGES.direction, "outbound")))
		.limit(1);
	const row = rows[0];
	if (!row?.prospectId) return null;
	return {
		prospectId: row.prospectId,
		stepKey: row.stepKey,
		templateName: row.templateName,
	};
}
 
/**
 * Has this exact (wamid, type) pair already landed?
 *
 * Scoped to the one type, mirroring `inbound.service.wasAlreadyProcessed`:
 * one message legitimately produces `message_delivered` AND `message_read`,
 * so "any event with this wamid" would drop the read after the delivery.
 * `json_extract` rather than a column for the same reason as inbound — the
 * payload is where a wamid lives, and this module owns no migrations.
 */
async function wasAlreadyRecorded(
	db: DrizzleD1Database<typeof schema>,
	wamid: string,
	type: string,
): Promise<boolean> {
	const rows = await db
		.select({ id: EVENTS.id })
		.from(EVENTS)
		.where(
			and(
				eq(EVENTS.type, type as (typeof EVENTS.type.enumValues)[number]),
				sql`json_extract(${EVENTS.payload}, '$.wamid') = ${wamid}`,
			),
		)
		.limit(1);
	return rows.length > 0;
}
 
/**
 * Meta sends `timestamp` as unix SECONDS in a string. `new Date("1756400000")`
 * is an Invalid Date and `new Date(1756400000)` is January 1970 — both would
 * put every delivery event outside every window the ramp and auto-halt
 * queries look at, which fails silently rather than loudly.
 *
 * Returns undefined for anything unparseable so the caller falls back to now.
 */
export function metaTimestampToDate(
	timestamp: string | undefined,
): Date | undefined {
	if (!timestamp) return undefined;
	const seconds = Number(timestamp);
	if (!Number.isFinite(seconds) || seconds <= 0) return undefined;
	return new Date(seconds * 1000);
}
 
/**
 * Records one Meta status against the prospect it belongs to.
 *
 * Never throws for "not ours" or "seen already" — both are ordinary outcomes
 * on a webhook Meta retries freely, and the caller must still return 200.
 */
export async function recordRrmDeliveryStatus(
	ctx: { db: DrizzleD1Database<typeof schema>; now?: Date },
	input: RrmDeliveryInput,
): Promise<RrmDeliveryOutcome> {
	const { db } = ctx;
	const now = ctx.now ?? new Date();
 
	if (input.status === "sent") {
		return { outcome: "ignored", reason: "sent_owned_by_gateway" };
	}
 
	const type = STATUS_EVENT[input.status];
	const message = await findRrmMessage(db, input.wamid);
	if (!message) return { outcome: "not_rrm" };
 
	if (await wasAlreadyRecorded(db, input.wamid, type)) {
		return { outcome: "duplicate", prospectId: message.prospectId };
	}
 
	// `detail` is a STRING, and it carries the raw code, because
	// `scheduler.service`'s account-restriction trigger regex-tests
	// `payload.detail` for `368|131031`. A structured `{ code, title }` object
	// would read as undefined there and the guard would stay blind — the exact
	// bug this module exists to fix. Keep the code in the string.
	const detail =
		input.status === "failed"
			? `whatsapp_status_failed: ${input.errorCode ?? "unknown"}${
					input.errorTitle ? ` ${input.errorTitle}` : ""
				}`
			: undefined;
 
	await new RrmEventsDal(db).append({
		prospectId: message.prospectId,
		type,
		actorType: "system",
		channel: "whatsapp",
		payload: {
			wamid: input.wamid,
			stepKey: message.stepKey,
			templateName: message.templateName,
			...(detail ? { detail } : {}),
		},
		occurredAt: input.occurredAt ?? now,
	});
 
	return { outcome: "recorded", prospectId: message.prospectId, type };
}