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