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 | 37x 37x 3x 34x 4x 30x 25x 25x 25x 25x 4x 25x 24x 24x 20x 20x 18x 25x 25x 25x | import type { RRM_CONSENT_BASES } from "../../db/schema/enums";
import type { WhatsAppInboundMessage } from "../whatsapp/types";
// DAY_MS is imported rather than redefined — one source for the constant.
import { DAY_MS } from "./time";
/**
* Consent to be messaged — the PERSON half of WhatsApp compliance.
*
* Two things get conflated constantly, and only one of them lives here:
*
* 1. **Meta approving a template** is approval of message CONTENT. It says
* nothing about who may receive it.
* 2. **A person's consent** is permission to message THAT NUMBER at all.
*
* A business-initiated send needs both. An approved template sent to someone
* who never opted in is still a policy violation, and under Indian law it is
* still processing without a lawful basis. Meta's Business Messaging Policy:
* "You may only contact people on WhatsApp if: (a) they have given you their
* mobile phone number; and (b) you have received opt-in permission from the
* recipient confirming that they wish to receive subsequent messages or calls
* from you." (https://business.whatsapp.com/policy)
*
* Under s.6(10) of the DPDP Act 2023 the burden of proving consent is ours,
* not the complainant's — so "we think they were fine with it" is not a
* defence, and a basis with no evidence is worth very little. That is why
* `consent_basis` and `consent_evidence` are stored together.
*
* The reasoning, the policy quotes and the operator procedure are in
* docs/operations/whatsapp-consent.md.
*
* SCOPE — this module answers ONE question: has this person given us a basis
* to open a conversation? It deliberately does NOT check `do_not_contact`,
* `rrm_suppression`, the 24-hour window, the daily cap or the marketing halt
* flag. Those gates exist elsewhere and re-implementing them here would give
* the campaign two answers to the same question — the failure mode this
* schema's own comments call out. Every one of them still has to pass.
*/
export type ConsentBasis = (typeof RRM_CONSENT_BASES)[number];
/** A basis that permits opening a conversation. `none` is not one of them. */
export type ConsentingBasis = Exclude<ConsentBasis, "none">;
/** Shape of `rrm_prospects.consent_evidence`. */
export type ConsentEvidence = {
/** Meta's id for the message that proves it. Not re-fetchable later. */
wamid?: string;
/** The words they actually sent or the box they actually ticked. */
messageText?: string;
/** `rrm_submissions.id` when the basis is `form_submitted`. */
formSubmissionId?: string;
/** ISO 8601. When we captured the evidence, not when they acted. */
capturedAt?: string;
};
/** The columns this decision reads. A full `RrmProspect` row satisfies it. */
export type ConsentBearingProspect = {
consentBasis: ConsentBasis | null | undefined;
};
export type ConsentDenyReason =
/** `consent_basis = 'none'`: we hold the number and nothing else. */
| "no_consent"
/** NULL — a row written before the column existed, or by raw SQL. */
| "consent_unrecorded";
export type BusinessInitiatedDecision =
| { allowed: true; basis: ConsentingBasis }
| { allowed: false; reason: ConsentDenyReason; message: string };
/**
* May we START a conversation with this person?
*
* "Business-initiated" means the first message of a new conversation: a
* template send, an outreach step, an operator typing into the inbox when the
* 24-hour window is shut. Replying inside an open window is a different
* question with a different gate.
*
* This is the code-level form of the rule, and it is here rather than inline
* in a route on purpose: the person it protects against is not a malicious
* operator but a well-meaning one, looking at 400 imported numbers and a
* working send button.
*/
export function canSendBusinessInitiated(
prospect: ConsentBearingProspect,
): BusinessInitiatedDecision {
const basis = prospect.consentBasis;
if (basis === null || basis === undefined) {
return {
allowed: false,
reason: "consent_unrecorded",
message:
"No consent basis recorded for this prospect. Record how consent was obtained before sending.",
};
}
if (basis === "none") {
return {
allowed: false,
reason: "no_consent",
message:
"This person has not opted in — we hold their number only. Wait for them to message us first, or capture consent another way.",
};
}
// `public_listing` reaches here and IS allowed — but it is not consent, and
// the code should not let a reader think it is. Nobody opted in; a named
// person decided that contacting a publicly listed business number is
// defensible, and that decision is on the row with their name against it.
//
// Kept as an allow rather than a fourth branch because the send path has
// exactly one question — may this template go out — and answering it in two
// places is how the gate ends up disagreeing with itself. What differs is
// the EVIDENCE, and that is the auditor's question, not the sender's.
return { allowed: true, basis };
}
/**
* Is this basis an act by the PERSON, or a decision by US?
*
* The send gate does not care — both allow a template. Everything that has to
* explain itself to a recipient, a regulator or Meta does: "you asked us to
* message you" and "we decided we could" are not the same sentence, and the
* one screen that shows a partner why they are hearing from us must never
* print the second as if it were the first.
*/
export function isOptIn(basis: ConsentBasis | null | undefined): boolean {
return (
basis === "user_initiated" ||
basis === "form_submitted" ||
basis === "stated_in_chat"
);
}
/** What an inbound message can establish. See RRM_CONSENT_BASES. */
type InboundBasis = Extract<ConsentBasis, "user_initiated" | "stated_in_chat">;
export type InboundConsentOptions = {
/**
* `user_initiated` (default) for the message that OPENS a thread — they
* tapped `wa.me` or an ad and wrote to us first.
*
* `stated_in_chat` for an affirmative reply inside a thread we opened.
* Weaker, because the words came after we had already asked, so the
* distinction is worth keeping rather than flattening.
*/
basis?: InboundBasis;
/** The notice version live when this arrived, e.g. `partners-v1-2026-08`. */
consentVersion?: string | null;
/** Fallback instant when Meta's timestamp is unusable. Defaults to now. */
receivedAt?: Date;
};
/** The `rrm_prospects` patch an inbound message justifies. */
export type InboundConsentRecord = {
consentBasis: InboundBasis;
consentVersion: string | null;
consentAt: Date;
consentEvidence: ConsentEvidence;
};
/**
* Builds the consent record for an inbound WhatsApp message.
*
* The message body is copied into the evidence rather than referenced,
* because the Cloud API does not let us read a message back later: if we do
* not keep the words at the moment the webhook fires, the answer to "what
* exactly did they say?" is gone.
*/
export function consentFromInbound(
message: WhatsAppInboundMessage,
options: InboundConsentOptions = {},
): InboundConsentRecord {
const text = inboundText(message);
const wamid = message.id?.trim();
// One instant for the skew bound, the fallback and the evidence stamp:
// three `new Date()` calls would disagree by milliseconds for no reason.
const receivedAt = options.receivedAt ?? new Date();
return {
consentBasis: options.basis ?? "user_initiated",
consentVersion: options.consentVersion ?? null,
consentAt:
parseInboundTimestamp(message.timestamp, receivedAt) ?? receivedAt,
consentEvidence: {
// Omitted when blank: a wamid that points at nothing is not evidence,
// and an empty string in the column reads like one that does.
...(wamid ? { wamid } : {}),
...(text ? { messageText: text } : {}),
capturedAt: receivedAt.toISOString(),
},
};
}
/**
* How far ahead of the moment we received the message an inbound timestamp
* may sit before we stop believing it.
*
* Meta's clock and ours drift by seconds, so a year of slack rejects nothing
* legitimate. What it does reject is a value in the wrong UNIT.
*/
const MAX_TIMESTAMP_SKEW_MS = 365 * DAY_MS;
/**
* Meta sends epoch SECONDS as a string.
*
* The unit is the whole risk here. A millisecond value is finite and positive,
* so neither of the cheap guards catches it, and ×1000 dates the consent to
* roughly the year 58600. That would land in `rrm_prospects.consent_at` — the
* field we have to produce under DPDP s.6(10) when someone asks when they
* agreed — so a timestamp we cannot believe is rejected outright and the
* caller falls back to the instant we received the message, rather than being
* coerced into a plausible-looking wrong date.
*
* There is deliberately no matching lower bound beyond `> 0`: a timestamp
* older than we expect is still the timestamp Meta reported, and second-
* guessing it would discard real evidence.
*/
function parseInboundTimestamp(
timestamp: string | undefined,
receivedAt: Date,
): Date | null {
if (!timestamp) return null;
const seconds = Number(timestamp);
if (!Number.isFinite(seconds) || seconds <= 0) return null;
const ms = seconds * 1000;
if (ms > receivedAt.getTime() + MAX_TIMESTAMP_SKEW_MS) return null;
return new Date(ms);
}
/**
* The words to keep as evidence. A tapped button counts — the title is what
* the person saw and chose, which is the "clear affirmative action" s.6(1) of
* the DPDP Act asks for. A photo with no caption leaves only the wamid.
*/
function inboundText(message: WhatsAppInboundMessage): string | undefined {
const candidate =
message.text?.body ??
message.interactive?.button_reply?.title ??
message.interactive?.list_reply?.title ??
message.image?.caption ??
message.video?.caption ??
message.document?.caption;
const trimmed = candidate?.trim();
return trimmed ? trimmed : undefined;
}
|