All files / dal/rrm suppression.dal.ts

100% Statements 11/11
75% Branches 3/4
100% Functions 6/6
100% Lines 11/11

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            24x                                             647x               56x         56x         498x         498x                                                   35x                   35x         3x                         3x       3x      
import { desc, eq, gte, sql } from "drizzle-orm";
import type { DrizzleD1Database } from "drizzle-orm/d1";
import * as schema from "../../db/schema";
import type { RRM_SUPPRESSION_REASONS } from "../../db/schema/enums";
import type { RrmSuppression } from "../../db/schema/rrm";
 
const SUPPRESSION = schema.rrmSuppression;
 
export type RrmSuppressionReason = (typeof RRM_SUPPRESSION_REASONS)[number];
 
/** What proves the suppression. A wamid alone is not readable at 2am. */
export type RrmSuppressionEvidence = {
	/** The wamid of the message that asked us to stop. */
	messageId?: string;
	/** Their words, verbatim. Inbound bodies are not re-fetchable from Meta. */
	body?: string;
};
 
/**
 * The global block list: permanent, keyed on phone rather than prospect id, and
 * consulted before every single send.
 *
 * Phone-keyed because the row has to outlive the prospect. A number that opted
 * out must stay blocked after a DPDP erasure nulls its prospect row, and after
 * the same list is scraped and re-imported next month — which is exactly when
 * a prospect-keyed suppression would silently let the campaign message an
 * angry person a second time.
 */
export class RrmSuppressionDal {
	constructor(private db: DrizzleD1Database<typeof schema>) {}
 
	/**
	 * The send gate. One primary-key lookup, one projected column — every
	 * outbound message pays this cost, so it stays a point read and never grows
	 * a join or a scan.
	 */
	async isSuppressed(phoneNorm: string): Promise<boolean> {
		const rows = await this.db
			.select({ phoneNorm: SUPPRESSION.phoneNorm })
			.from(SUPPRESSION)
			.where(eq(SUPPRESSION.phoneNorm, phoneNorm))
			.limit(1);
		return rows.length > 0;
	}
 
	/** The stored suppression, for answering "why is this number blocked?". */
	async find(phoneNorm: string): Promise<RrmSuppression | undefined> {
		const rows = await this.db
			.select()
			.from(SUPPRESSION)
			.where(eq(SUPPRESSION.phoneNorm, phoneNorm))
			.limit(1);
		return rows[0];
	}
 
	/**
	 * Suppresses a number. Idempotent, and deliberately FIRST-WRITE-WINS.
	 *
	 * A repeat call neither throws nor overwrites: the original reason,
	 * evidence and timestamp survive untouched. That is not politeness about
	 * duplicate webhooks, it is the evidential point — under s.6(10) of the
	 * DPDP Act we carry the burden of proof, and the artefact that matters is
	 * the FIRST time they told us to stop, together with when they said it. A
	 * later `manual` suppression overwriting a three-week-old `opt_out` would
	 * destroy both the wording and the date we would have to produce.
	 *
	 * `ON CONFLICT DO NOTHING` rather than read-then-write, so two webhook
	 * deliveries racing each other cannot both see "absent" and both insert.
	 *
	 * @returns true when THIS call created the row, false when the number was
	 *   already suppressed. Callers use it to append the `opted_out` event (and
	 *   send the acknowledgement) exactly once.
	 */
	async suppress(
		phoneNorm: string,
		reason: RrmSuppressionReason,
		evidence?: RrmSuppressionEvidence | null,
	): Promise<boolean> {
		const inserted = await this.db
			.insert(SUPPRESSION)
			.values({
				phoneNorm,
				reason,
				evidence: evidence ?? null,
				dateCreated: new Date(),
			})
			.onConflictDoNothing()
			.returning({ phoneNorm: SUPPRESSION.phoneNorm });
		return inserted.length > 0;
	}
 
	/** Newest first. Feeds the block/opt-out ratio on the dashboard. */
	async listSince(since: Date): Promise<RrmSuppression[]> {
		return await this.db
			.select()
			.from(SUPPRESSION)
			.where(gte(SUPPRESSION.dateCreated, since))
			.orderBy(desc(SUPPRESSION.dateCreated));
	}
 
	/**
	 * The numerator of the opt-out ratio. Counted in SQL rather than by taking
	 * `listSince().length`, because the ramp gate reads it on every tick and
	 * the ratio is the metric that stops the campaign.
	 */
	async countSince(since: Date): Promise<number> {
		const rows = await this.db
			.select({ count: sql<number>`count(*)` })
			.from(SUPPRESSION)
			.where(gte(SUPPRESSION.dateCreated, since));
		return rows[0]?.count ?? 0;
	}
}