@blamejs/blamejs-shop 0.0.59 → 0.0.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,945 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.smsDispatcher
4
+ * @title SMS dispatcher — outbound SMS layer with per-country routing
5
+ *
6
+ * @intro
7
+ * Outbound SMS layer. Operators register one provider per country
8
+ * (Twilio for US/CA, MessageBird for the EU, Plivo for AU, ...);
9
+ * the dispatcher routes each enqueue to the first active provider
10
+ * whose region list covers the recipient's `country_code`. Customer
11
+ * opt-in is tracked per channel (`marketing` / `transactional` /
12
+ * `verification`) — non-transactional sends to a phone without the
13
+ * matching opt-in row are refused at enqueue time, and any inbound
14
+ * "STOP" / "UNSUBSCRIBE" / "CANCEL" keyword registers an opt-out
15
+ * plus a single confirmation message.
16
+ *
17
+ * The shape:
18
+ *
19
+ * var sms = bShop.smsDispatcher.create({ query: q });
20
+ *
21
+ * await sms.registerProvider({
22
+ * slug: "twilio",
23
+ * name: "Twilio",
24
+ * regions: ["US", "CA"],
25
+ * endpoint_url: "https://api.twilio.com/2010-04-01/Accounts/...",
26
+ * active: true,
27
+ * });
28
+ *
29
+ * await sms.recordOptIn({
30
+ * customer_id: customerId,
31
+ * phone_hash: sms.hashPhone("+15555550123"),
32
+ * phone_normalized: "+15555550123",
33
+ * channel: "marketing",
34
+ * opt_in_text: "YES",
35
+ * });
36
+ *
37
+ * await sms.enqueue({
38
+ * recipient_customer_id: customerId,
39
+ * recipient_phone: "+15555550123",
40
+ * country_code: "US",
41
+ * body: "Your order has shipped.",
42
+ * kind: "transactional",
43
+ * });
44
+ *
45
+ * // Scheduler tick — operator wires this to a cron / Workers
46
+ * // Cron Trigger. Pulls every queued message whose schedule_at
47
+ * // is due, hands it to the provider-wired send hook, and flips
48
+ * // status to `sent`. The send hook stamps provider_ref via
49
+ * // `markDelivered` when the provider's delivery-receipt webhook
50
+ * // arrives; transient failures flow through `markFailed` with
51
+ * // `retry: true` so the row re-queues with back-off.
52
+ * await sms.dispatchTick({ now: Date.now() });
53
+ *
54
+ * FSM:
55
+ * queued → sent → delivered
56
+ * ↘ failed (retry → queued | terminal)
57
+ *
58
+ * Composition: zero npm runtime deps; the primitive composes
59
+ * blamejs (`b.uuid.v7`, `b.crypto.namespaceHash`, `b.guardUuid`).
60
+ *
61
+ * @related b.crypto.namespaceHash, b.uuid, b.guardUuid
62
+ */
63
+
64
+ var bShop;
65
+ function _b() {
66
+ if (!bShop) bShop = require("./index");
67
+ return bShop.framework;
68
+ }
69
+
70
+ // ---- constants ----------------------------------------------------------
71
+
72
+ var PHONE_NAMESPACE = "sms-phone";
73
+
74
+ var CHANNELS = ["marketing", "transactional", "verification"];
75
+ var STATES = ["opt_in", "opt_out"];
76
+ var KINDS = ["transactional", "marketing", "verification"];
77
+ var STATUSES = ["queued", "sent", "delivered", "failed"];
78
+
79
+ var MAX_SLUG_LEN = 64;
80
+ var MAX_NAME_LEN = 128;
81
+ var MAX_URL_LEN = 2048;
82
+ var MAX_BODY_LEN = 1600; // 10 concatenated SMS segments at 160 chars
83
+ var MIN_BODY_LEN = 1;
84
+ var MAX_REASON_LEN = 280;
85
+ var MAX_OPT_IN_TEXT_LEN = 280;
86
+ var MAX_REGIONS = 256;
87
+ var MAX_LIST_LIMIT = 500;
88
+ var DEFAULT_LIST_LIMIT = 100;
89
+ var MAX_BATCH_SIZE = 1000;
90
+ var DEFAULT_BATCH_SIZE = 100;
91
+ var MAX_PROVIDER_REF_LEN = 256;
92
+
93
+ // Transient-failure retry back-off — 1m, 5m, 30m, 2h, 12h. The
94
+ // dispatch tick consults the schedule on `markFailed({ retry: true })`
95
+ // to compute `next_retry_at`; once the schedule is exhausted the row
96
+ // terminates as failed regardless of the caller's `retry` request.
97
+ var RETRY_BACKOFF_MS = [
98
+ 60 * 1000,
99
+ 5 * 60 * 1000,
100
+ 30 * 60 * 1000,
101
+ 2 * 60 * 60 * 1000,
102
+ 12 * 60 * 60 * 1000,
103
+ ];
104
+
105
+ var SLUG_RE = /^[a-z](?:[a-z0-9-]*[a-z0-9])?$/;
106
+ var COUNTRY_RE = /^[A-Z]{2}$/;
107
+ var PHONE_HASH_RE = /^[0-9a-f]{128}$/;
108
+ var PHONE_NORM_RE = /^\+[1-9][0-9]{6,14}$/; // E.164: +<country><subscriber>, 7-15 digits total
109
+ var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
110
+ var ZERO_WIDTH_RE = new RegExp(
111
+ "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
112
+ );
113
+
114
+ // Inbound STOP-keyword detection. Operators in the US/UK/AU regulatory
115
+ // envelope have all converged on this short list; the primitive
116
+ // matches the body case-insensitively, ignoring leading/trailing
117
+ // whitespace, so " stop " and "STOP" both register an opt-out.
118
+ var STOP_KEYWORDS = ["STOP", "UNSUBSCRIBE", "CANCEL", "END", "QUIT", "STOPALL"];
119
+
120
+ // Emoji code-point ranges — anything in the BMP emoji block, the
121
+ // supplementary-multilingual-plane emoji block, or the variation
122
+ // selectors. The check refuses bodies whose ONLY non-whitespace
123
+ // content is emoji — a degenerate payload that wastes a provider
124
+ // segment without conveying anything to a screen reader.
125
+ var EMOJI_CODEPOINT_RANGES = [
126
+ [0x1F300, 0x1FAFF], // misc symbols + pictographs through symbols-and-pictographs-extended-A
127
+ [0x2600, 0x27BF], // misc symbols + dingbats
128
+ [0x1F000, 0x1F0FF], // mahjong + dominoes + cards
129
+ [0x1F100, 0x1F1FF], // enclosed alphanumeric supplement (incl. regional-indicator pairs)
130
+ ];
131
+ var VARIATION_SELECTORS = [0xFE0F, 0xFE0E];
132
+ var ZERO_WIDTH_JOINER = 0x200D;
133
+
134
+ // ---- validators ---------------------------------------------------------
135
+
136
+ function _slug(s, label) {
137
+ if (typeof s !== "string" || !s.length) {
138
+ throw new TypeError("smsDispatcher: " + label + " must be a non-empty string");
139
+ }
140
+ if (s.length > MAX_SLUG_LEN) {
141
+ throw new TypeError("smsDispatcher: " + label + " must be <= " + MAX_SLUG_LEN + " characters");
142
+ }
143
+ if (!SLUG_RE.test(s)) {
144
+ throw new TypeError("smsDispatcher: " + label + " must match /[a-z][a-z0-9-]*[a-z0-9]/");
145
+ }
146
+ return s;
147
+ }
148
+
149
+ function _name(s) {
150
+ if (typeof s !== "string" || !s.length) {
151
+ throw new TypeError("smsDispatcher: name must be a non-empty string");
152
+ }
153
+ if (s.length > MAX_NAME_LEN) {
154
+ throw new TypeError("smsDispatcher: name must be <= " + MAX_NAME_LEN + " characters");
155
+ }
156
+ if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
157
+ throw new TypeError("smsDispatcher: name contains control / zero-width bytes");
158
+ }
159
+ return s;
160
+ }
161
+
162
+ function _endpointUrl(s) {
163
+ if (typeof s !== "string" || !s.length) {
164
+ throw new TypeError("smsDispatcher: endpoint_url must be a non-empty string");
165
+ }
166
+ if (s.length > MAX_URL_LEN) {
167
+ throw new TypeError("smsDispatcher: endpoint_url must be <= " + MAX_URL_LEN + " characters");
168
+ }
169
+ if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
170
+ throw new TypeError("smsDispatcher: endpoint_url contains control / zero-width bytes");
171
+ }
172
+ // Shape gate — refuse anything that isn't an absolute https URL.
173
+ // Provider endpoints are public APIs; operators using a non-https
174
+ // egress are leaking the message body across the wire.
175
+ if (!/^https:\/\//i.test(s)) {
176
+ throw new TypeError("smsDispatcher: endpoint_url must be an absolute https:// URL");
177
+ }
178
+ return s;
179
+ }
180
+
181
+ function _country(s) {
182
+ if (typeof s !== "string") {
183
+ throw new TypeError("smsDispatcher: country_code must be a string");
184
+ }
185
+ var up = s.toUpperCase();
186
+ if (!COUNTRY_RE.test(up)) {
187
+ throw new TypeError("smsDispatcher: country_code must be ISO-3166 alpha-2 (e.g. 'US')");
188
+ }
189
+ return up;
190
+ }
191
+
192
+ function _regions(arr) {
193
+ if (!Array.isArray(arr) || arr.length === 0) {
194
+ throw new TypeError("smsDispatcher: regions must be a non-empty array");
195
+ }
196
+ if (arr.length > MAX_REGIONS) {
197
+ throw new TypeError("smsDispatcher: regions must have <= " + MAX_REGIONS + " entries");
198
+ }
199
+ var out = [];
200
+ var seen = {};
201
+ for (var i = 0; i < arr.length; i += 1) {
202
+ var c = _country(arr[i]);
203
+ if (seen[c]) {
204
+ throw new TypeError("smsDispatcher: regions contains duplicate entry " + JSON.stringify(c));
205
+ }
206
+ seen[c] = true;
207
+ out.push(c);
208
+ }
209
+ return out;
210
+ }
211
+
212
+ function _channel(s) {
213
+ if (typeof s !== "string" || CHANNELS.indexOf(s) === -1) {
214
+ throw new TypeError("smsDispatcher: channel must be one of " + CHANNELS.join(", "));
215
+ }
216
+ return s;
217
+ }
218
+
219
+ function _kind(s) {
220
+ if (typeof s !== "string" || KINDS.indexOf(s) === -1) {
221
+ throw new TypeError("smsDispatcher: kind must be one of " + KINDS.join(", "));
222
+ }
223
+ return s;
224
+ }
225
+
226
+ function _phoneNormalized(s) {
227
+ if (typeof s !== "string" || !s.length) {
228
+ throw new TypeError("smsDispatcher: phone_normalized must be a non-empty string");
229
+ }
230
+ if (!PHONE_NORM_RE.test(s)) {
231
+ throw new TypeError("smsDispatcher: phone_normalized must be E.164 (e.g. +15555550123)");
232
+ }
233
+ return s;
234
+ }
235
+
236
+ function _phoneHash(s) {
237
+ if (typeof s !== "string" || !s.length) {
238
+ throw new TypeError("smsDispatcher: phone_hash must be a non-empty string");
239
+ }
240
+ if (!PHONE_HASH_RE.test(s)) {
241
+ throw new TypeError("smsDispatcher: phone_hash must be 128 lowercase hex characters");
242
+ }
243
+ return s;
244
+ }
245
+
246
+ function _uuid(s, label) {
247
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
248
+ catch (e) {
249
+ throw new TypeError("smsDispatcher: " + label + " — " + (e && e.message || "invalid UUID"));
250
+ }
251
+ }
252
+
253
+ function _optionalUuid(s, label) {
254
+ if (s == null) return null;
255
+ return _uuid(s, label);
256
+ }
257
+
258
+ function _reason(s, label) {
259
+ if (s == null) return null;
260
+ if (typeof s !== "string" || !s.length) {
261
+ throw new TypeError("smsDispatcher: " + label + " must be a non-empty string when provided");
262
+ }
263
+ if (s.length > MAX_REASON_LEN) {
264
+ throw new TypeError("smsDispatcher: " + label + " must be <= " + MAX_REASON_LEN + " characters");
265
+ }
266
+ if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
267
+ throw new TypeError("smsDispatcher: " + label + " contains control / zero-width bytes");
268
+ }
269
+ return s;
270
+ }
271
+
272
+ function _optInText(s) {
273
+ if (s == null) return null;
274
+ if (typeof s !== "string" || !s.length) {
275
+ throw new TypeError("smsDispatcher: opt_in_text must be a non-empty string when provided");
276
+ }
277
+ if (s.length > MAX_OPT_IN_TEXT_LEN) {
278
+ throw new TypeError("smsDispatcher: opt_in_text must be <= " + MAX_OPT_IN_TEXT_LEN + " characters");
279
+ }
280
+ if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
281
+ throw new TypeError("smsDispatcher: opt_in_text contains control / zero-width bytes");
282
+ }
283
+ return s;
284
+ }
285
+
286
+ function _providerRef(s) {
287
+ if (typeof s !== "string" || !s.length) {
288
+ throw new TypeError("smsDispatcher: provider_ref must be a non-empty string");
289
+ }
290
+ if (s.length > MAX_PROVIDER_REF_LEN) {
291
+ throw new TypeError("smsDispatcher: provider_ref must be <= " + MAX_PROVIDER_REF_LEN + " characters");
292
+ }
293
+ if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
294
+ throw new TypeError("smsDispatcher: provider_ref contains control / zero-width bytes");
295
+ }
296
+ return s;
297
+ }
298
+
299
+ function _epochMs(n, label) {
300
+ if (!Number.isInteger(n) || n <= 0) {
301
+ throw new TypeError("smsDispatcher: " + label + " must be a positive integer (epoch ms)");
302
+ }
303
+ return n;
304
+ }
305
+
306
+ function _optionalEpochMs(n, label) {
307
+ if (n == null) return null;
308
+ return _epochMs(n, label);
309
+ }
310
+
311
+ function _limit(n, label) {
312
+ if (n == null) return DEFAULT_LIST_LIMIT;
313
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
314
+ throw new TypeError("smsDispatcher: " + label + " must be an integer in [1, " + MAX_LIST_LIMIT + "]");
315
+ }
316
+ return n;
317
+ }
318
+
319
+ function _batchSize(n) {
320
+ if (n == null) return DEFAULT_BATCH_SIZE;
321
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_BATCH_SIZE) {
322
+ throw new TypeError("smsDispatcher: batch_size must be an integer in [1, " + MAX_BATCH_SIZE + "]");
323
+ }
324
+ return n;
325
+ }
326
+
327
+ // Body validator — refuses emoji-only / control-byte payloads. The
328
+ // emoji refusal is a discipline check: a body that's nothing but
329
+ // pictographs wastes a provider segment without conveying anything
330
+ // to a screen reader or a recipient on a feature phone. Operators
331
+ // that want pictographs mix them with text.
332
+ function _body(s) {
333
+ if (typeof s !== "string" || s.length < MIN_BODY_LEN) {
334
+ throw new TypeError("smsDispatcher: body must be a non-empty string");
335
+ }
336
+ if (s.length > MAX_BODY_LEN) {
337
+ throw new TypeError("smsDispatcher: body must be <= " + MAX_BODY_LEN + " characters");
338
+ }
339
+ if (CONTROL_BYTE_RE.test(s)) {
340
+ throw new TypeError("smsDispatcher: body contains control bytes");
341
+ }
342
+ if (ZERO_WIDTH_RE.test(s)) {
343
+ throw new TypeError("smsDispatcher: body contains zero-width / bidi-override codepoints");
344
+ }
345
+ // Refuse the degenerate emoji-only shape. Walk the string by code
346
+ // point so surrogate pairs in the SMP emoji blocks count as one
347
+ // character.
348
+ var hasNonEmoji = false;
349
+ var cp;
350
+ for (var i = 0; i < s.length; ) {
351
+ cp = s.codePointAt(i);
352
+ i += cp > 0xFFFF ? 2 : 1;
353
+ if (cp === 0x20 || cp === 0x09 || cp === 0x0A || cp === 0x0D) continue;
354
+ if (cp === ZERO_WIDTH_JOINER) continue;
355
+ if (VARIATION_SELECTORS.indexOf(cp) !== -1) continue;
356
+ var inEmoji = false;
357
+ for (var r = 0; r < EMOJI_CODEPOINT_RANGES.length; r += 1) {
358
+ if (cp >= EMOJI_CODEPOINT_RANGES[r][0] && cp <= EMOJI_CODEPOINT_RANGES[r][1]) {
359
+ inEmoji = true;
360
+ break;
361
+ }
362
+ }
363
+ if (!inEmoji) {
364
+ hasNonEmoji = true;
365
+ break;
366
+ }
367
+ }
368
+ if (!hasNonEmoji) {
369
+ throw new TypeError("smsDispatcher: body must not be emoji-only");
370
+ }
371
+ return s;
372
+ }
373
+
374
+ function _now() { return Date.now(); }
375
+
376
+ function _isStopKeyword(raw) {
377
+ if (typeof raw !== "string") return false;
378
+ var trimmed = raw.trim().toUpperCase();
379
+ if (!trimmed) return false;
380
+ return STOP_KEYWORDS.indexOf(trimmed) !== -1;
381
+ }
382
+
383
+ // ---- factory ------------------------------------------------------------
384
+
385
+ function create(opts) {
386
+ opts = opts || {};
387
+ var query = opts.query;
388
+ if (!query) {
389
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
390
+ }
391
+
392
+ // -------- internal helpers --------
393
+
394
+ function hashPhone(phoneNormalized) {
395
+ _phoneNormalized(phoneNormalized);
396
+ return _b().crypto.namespaceHash(PHONE_NAMESPACE, phoneNormalized);
397
+ }
398
+
399
+ async function _getProvider(slug) {
400
+ var r = await query("SELECT * FROM sms_providers WHERE slug = ?1", [slug]);
401
+ return r.rows[0] || null;
402
+ }
403
+
404
+ async function _getMessage(id) {
405
+ var r = await query("SELECT * FROM sms_messages WHERE id = ?1", [id]);
406
+ return r.rows[0] || null;
407
+ }
408
+
409
+ async function _getOptState(phoneHash, channel) {
410
+ var r = await query(
411
+ "SELECT * FROM sms_opt_state WHERE phone_hash = ?1 AND channel = ?2",
412
+ [phoneHash, channel],
413
+ );
414
+ return r.rows[0] || null;
415
+ }
416
+
417
+ // Provider routing — pick the first active provider whose
418
+ // regions_json contains the requested country code. Ties are
419
+ // broken by `created_at ASC` (oldest provider wins), so adding a
420
+ // second provider for the same region doesn't silently re-route
421
+ // existing traffic.
422
+ async function _resolveProvider(countryCode) {
423
+ var r = await query(
424
+ "SELECT * FROM sms_providers WHERE active = 1 ORDER BY created_at ASC, slug ASC",
425
+ );
426
+ for (var i = 0; i < r.rows.length; i += 1) {
427
+ var row = r.rows[i];
428
+ var regions;
429
+ try { regions = JSON.parse(row.regions_json); }
430
+ catch (_e) { continue; }
431
+ if (!Array.isArray(regions)) continue;
432
+ if (regions.indexOf(countryCode) !== -1) return row;
433
+ }
434
+ return null;
435
+ }
436
+
437
+ // -------- public surface --------
438
+
439
+ return {
440
+ // Surface the constants the operator's authoring code consults.
441
+ CHANNELS: CHANNELS.slice(),
442
+ STATES: STATES.slice(),
443
+ KINDS: KINDS.slice(),
444
+ STATUSES: STATUSES.slice(),
445
+ STOP_KEYWORDS: STOP_KEYWORDS.slice(),
446
+ RETRY_BACKOFF_MS: RETRY_BACKOFF_MS.slice(),
447
+ PHONE_NAMESPACE: PHONE_NAMESPACE,
448
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
449
+ MAX_BODY_LEN: MAX_BODY_LEN,
450
+ MAX_LIST_LIMIT: MAX_LIST_LIMIT,
451
+ DEFAULT_LIST_LIMIT: DEFAULT_LIST_LIMIT,
452
+ MAX_BATCH_SIZE: MAX_BATCH_SIZE,
453
+ DEFAULT_BATCH_SIZE: DEFAULT_BATCH_SIZE,
454
+
455
+ // Helper surfaced so callers compose with the same hash function
456
+ // the primitive uses internally. Refuses non-E.164 input so a
457
+ // caller can't accidentally hash an ambiguous national-format
458
+ // phone number that won't match the dispatcher's opt-state row.
459
+ hashPhone: hashPhone,
460
+
461
+ registerProvider: async function (input) {
462
+ if (!input || typeof input !== "object") {
463
+ throw new TypeError("smsDispatcher.registerProvider: input object required");
464
+ }
465
+ var slug = _slug(input.slug, "slug");
466
+ var name = _name(input.name);
467
+ var regions = _regions(input.regions);
468
+ var endpointUrl = _endpointUrl(input.endpoint_url);
469
+ var active = input.active == null ? true : !!input.active;
470
+ var now = _now();
471
+ var json = JSON.stringify(regions);
472
+
473
+ var existing = await _getProvider(slug);
474
+ if (existing) {
475
+ await query(
476
+ "UPDATE sms_providers SET name = ?1, regions_json = ?2, " +
477
+ "endpoint_url = ?3, active = ?4, updated_at = ?5 WHERE slug = ?6",
478
+ [name, json, endpointUrl, active ? 1 : 0, now, slug],
479
+ );
480
+ } else {
481
+ await query(
482
+ "INSERT INTO sms_providers " +
483
+ "(slug, name, regions_json, endpoint_url, active, created_at, updated_at) " +
484
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6)",
485
+ [slug, name, json, endpointUrl, active ? 1 : 0, now],
486
+ );
487
+ }
488
+ return await _getProvider(slug);
489
+ },
490
+
491
+ providerForRegion: async function (countryCode) {
492
+ var c = _country(countryCode);
493
+ return await _resolveProvider(c);
494
+ },
495
+
496
+ recordOptIn: async function (input) {
497
+ if (!input || typeof input !== "object") {
498
+ throw new TypeError("smsDispatcher.recordOptIn: input object required");
499
+ }
500
+ var customerId = _optionalUuid(input.customer_id, "customer_id");
501
+ var phoneHash = _phoneHash(input.phone_hash);
502
+ _phoneNormalized(input.phone_normalized);
503
+ var channel = _channel(input.channel);
504
+ var optInText = _optInText(input.opt_in_text);
505
+ var now = _now();
506
+
507
+ // Verify the phone_hash matches the supplied phone_normalized.
508
+ // Operators that hand-craft a hash should be caught early; the
509
+ // namespaceHash composition is deterministic so a mismatch
510
+ // means the caller built the row wrong.
511
+ var expected = _b().crypto.namespaceHash(PHONE_NAMESPACE, input.phone_normalized);
512
+ if (expected !== phoneHash) {
513
+ throw new TypeError("smsDispatcher.recordOptIn: phone_hash does not match phone_normalized");
514
+ }
515
+
516
+ var existing = await _getOptState(phoneHash, channel);
517
+ if (existing) {
518
+ await query(
519
+ "UPDATE sms_opt_state SET state = 'opt_in', customer_id = ?1, " +
520
+ "opt_in_text = ?2, reason = NULL, occurred_at = ?3 " +
521
+ "WHERE phone_hash = ?4 AND channel = ?5",
522
+ [customerId, optInText, now, phoneHash, channel],
523
+ );
524
+ return await _getOptState(phoneHash, channel);
525
+ }
526
+ var id = _b().uuid.v7();
527
+ await query(
528
+ "INSERT INTO sms_opt_state " +
529
+ "(id, phone_hash, customer_id, channel, state, opt_in_text, reason, occurred_at) " +
530
+ "VALUES (?1, ?2, ?3, ?4, 'opt_in', ?5, NULL, ?6)",
531
+ [id, phoneHash, customerId, channel, optInText, now],
532
+ );
533
+ return await _getOptState(phoneHash, channel);
534
+ },
535
+
536
+ recordOptOut: async function (input) {
537
+ if (!input || typeof input !== "object") {
538
+ throw new TypeError("smsDispatcher.recordOptOut: input object required");
539
+ }
540
+ var customerId = _optionalUuid(input.customer_id, "customer_id");
541
+ var phoneHash = _phoneHash(input.phone_hash);
542
+ var channel = input.channel == null ? null : _channel(input.channel);
543
+ var reason = _reason(input.reason, "reason");
544
+ var now = _now();
545
+
546
+ // Operator-supplied channel narrows the opt-out to one channel.
547
+ // Absence → opt-out applies to every channel; the STOP-keyword
548
+ // handler relies on the default-all behaviour to register a
549
+ // single inbound "STOP" against marketing + transactional +
550
+ // verification in one call.
551
+ var channels = channel ? [channel] : CHANNELS.slice();
552
+ var written = [];
553
+ for (var i = 0; i < channels.length; i += 1) {
554
+ var ch = channels[i];
555
+ var existing = await _getOptState(phoneHash, ch);
556
+ if (existing) {
557
+ await query(
558
+ "UPDATE sms_opt_state SET state = 'opt_out', customer_id = ?1, " +
559
+ "reason = ?2, occurred_at = ?3 " +
560
+ "WHERE phone_hash = ?4 AND channel = ?5",
561
+ [customerId, reason, now, phoneHash, ch],
562
+ );
563
+ } else {
564
+ var id = _b().uuid.v7();
565
+ await query(
566
+ "INSERT INTO sms_opt_state " +
567
+ "(id, phone_hash, customer_id, channel, state, opt_in_text, reason, occurred_at) " +
568
+ "VALUES (?1, ?2, ?3, ?4, 'opt_out', NULL, ?5, ?6)",
569
+ [id, phoneHash, customerId, ch, reason, now],
570
+ );
571
+ }
572
+ written.push(await _getOptState(phoneHash, ch));
573
+ }
574
+ return written;
575
+ },
576
+
577
+ isOptedIn: async function (input) {
578
+ if (!input || typeof input !== "object") {
579
+ throw new TypeError("smsDispatcher.isOptedIn: input object required");
580
+ }
581
+ var phoneHash = _phoneHash(input.phone_hash);
582
+ var channel = input.channel == null ? null : _channel(input.channel);
583
+
584
+ // No channel supplied → "any opt-in row qualifies." Used by
585
+ // callers that just want to know whether the recipient has ever
586
+ // consented to any kind of contact.
587
+ if (channel == null) {
588
+ var r = await query(
589
+ "SELECT state, channel FROM sms_opt_state " +
590
+ "WHERE phone_hash = ?1 AND state = 'opt_in' LIMIT 1",
591
+ [phoneHash],
592
+ );
593
+ return r.rows.length > 0;
594
+ }
595
+ var row = await _getOptState(phoneHash, channel);
596
+ return !!(row && row.state === "opt_in");
597
+ },
598
+
599
+ // Inbound message handler — operator's webhook receiver hands the
600
+ // raw inbound SMS body here; the primitive checks for a STOP
601
+ // keyword and registers an opt-out across every channel + queues
602
+ // a single confirmation message. Returns
603
+ // `{ matched: true, opt_state: [...], confirmation: <message> }`
604
+ // when a keyword matched, `{ matched: false }` otherwise.
605
+ handleInboundKeyword: async function (input) {
606
+ if (!input || typeof input !== "object") {
607
+ throw new TypeError("smsDispatcher.handleInboundKeyword: input object required");
608
+ }
609
+ var phoneHash = _phoneHash(input.phone_hash);
610
+ _phoneNormalized(input.phone_normalized);
611
+ var body = input.body;
612
+ if (typeof body !== "string") {
613
+ throw new TypeError("smsDispatcher.handleInboundKeyword: body must be a string");
614
+ }
615
+ if (!_isStopKeyword(body)) return { matched: false };
616
+
617
+ // Verify the phone_hash matches the supplied phone_normalized
618
+ // so a misrouted webhook can't opt-out the wrong row.
619
+ var expected = _b().crypto.namespaceHash(PHONE_NAMESPACE, input.phone_normalized);
620
+ if (expected !== phoneHash) {
621
+ throw new TypeError(
622
+ "smsDispatcher.handleInboundKeyword: phone_hash does not match phone_normalized"
623
+ );
624
+ }
625
+
626
+ var now = _now();
627
+ // Opt-out every channel.
628
+ var written = [];
629
+ for (var i = 0; i < CHANNELS.length; i += 1) {
630
+ var ch = CHANNELS[i];
631
+ var existing = await _getOptState(phoneHash, ch);
632
+ if (existing) {
633
+ await query(
634
+ "UPDATE sms_opt_state SET state = 'opt_out', reason = ?1, occurred_at = ?2 " +
635
+ "WHERE phone_hash = ?3 AND channel = ?4",
636
+ ["stop-keyword", now, phoneHash, ch],
637
+ );
638
+ } else {
639
+ var id = _b().uuid.v7();
640
+ await query(
641
+ "INSERT INTO sms_opt_state " +
642
+ "(id, phone_hash, customer_id, channel, state, opt_in_text, reason, occurred_at) " +
643
+ "VALUES (?1, ?2, NULL, ?3, 'opt_out', NULL, 'stop-keyword', ?4)",
644
+ [id, phoneHash, ch, now],
645
+ );
646
+ }
647
+ written.push(await _getOptState(phoneHash, ch));
648
+ }
649
+
650
+ // Queue the confirmation. STOP confirmations are mandatory in
651
+ // every major SMS regulatory regime (US TCPA, UK PECR, AU Spam
652
+ // Act); the message bypasses the opt-out gate by design (the
653
+ // recipient explicitly opted in to this single reply by sending
654
+ // STOP). country_code defaults to the operator-supplied value;
655
+ // when absent we fall back to the dispatcher's first active
656
+ // provider — operators that want a different default supply it.
657
+ var countryCode = input.country_code == null
658
+ ? null
659
+ : _country(input.country_code);
660
+ var providerSlug = null;
661
+ if (countryCode) {
662
+ var prov = await _resolveProvider(countryCode);
663
+ if (prov) providerSlug = prov.slug;
664
+ }
665
+ var confirmationBody = "You have been unsubscribed. Reply START to opt back in.";
666
+ var messageId = _b().uuid.v7();
667
+ await query(
668
+ "INSERT INTO sms_messages " +
669
+ "(id, recipient_phone_hash, country_code, kind, body, status, " +
670
+ " provider_slug, provider_ref, attempts, next_retry_at, failure_reason, " +
671
+ " schedule_at, sent_at, delivered_at, failed_at, customer_id, created_at) " +
672
+ "VALUES (?1, ?2, ?3, 'transactional', ?4, 'queued', " +
673
+ " ?5, NULL, 0, NULL, NULL, ?6, NULL, NULL, NULL, NULL, ?6)",
674
+ [messageId, phoneHash, countryCode || "XX", confirmationBody, providerSlug, now],
675
+ );
676
+ var confirmation = await _getMessage(messageId);
677
+ return { matched: true, opt_state: written, confirmation: confirmation };
678
+ },
679
+
680
+ enqueue: async function (input) {
681
+ if (!input || typeof input !== "object") {
682
+ throw new TypeError("smsDispatcher.enqueue: input object required");
683
+ }
684
+ var customerId = _optionalUuid(input.recipient_customer_id, "recipient_customer_id");
685
+ var phoneNormalized = _phoneNormalized(input.recipient_phone);
686
+ var countryCode = _country(input.country_code);
687
+ var body = _body(input.body);
688
+ var kind = _kind(input.kind);
689
+ var scheduleAt = input.schedule_at == null ? _now() : _epochMs(input.schedule_at, "schedule_at");
690
+ var now = _now();
691
+ var phoneHash = _b().crypto.namespaceHash(PHONE_NAMESPACE, phoneNormalized);
692
+
693
+ // Opt-in gate. Transactional sends bypass the marketing
694
+ // opt-out — order receipts, ship notifications, refund
695
+ // confirmations are non-marketing and the regulatory envelope
696
+ // (US TCPA, UK PECR) treats them separately. A `transactional`
697
+ // send still refuses when the recipient has an explicit
698
+ // `transactional` opt-out row (operator-acknowledged
699
+ // do-not-contact). A `verification` send refuses on a
700
+ // verification opt-out. A `marketing` send refuses on either
701
+ // a marketing opt-out OR the absence of a marketing opt-in
702
+ // row (opt-IN semantics — marketing is on the strictest tier).
703
+ if (kind === "marketing") {
704
+ var marketingRow = await _getOptState(phoneHash, "marketing");
705
+ if (!marketingRow || marketingRow.state !== "opt_in") {
706
+ var mErr = new Error("smsDispatcher.enqueue: recipient is not opted-in to marketing");
707
+ mErr.code = "NOT_OPTED_IN";
708
+ throw mErr;
709
+ }
710
+ } else {
711
+ // transactional / verification — refuse only on explicit
712
+ // opt-out for the matching channel.
713
+ var explicitRow = await _getOptState(phoneHash, kind);
714
+ if (explicitRow && explicitRow.state === "opt_out") {
715
+ var oErr = new Error("smsDispatcher.enqueue: recipient opted-out of " + kind);
716
+ oErr.code = "OPTED_OUT";
717
+ throw oErr;
718
+ }
719
+ }
720
+
721
+ // Provider routing. No provider for the country → refuse so the
722
+ // operator catches the missing wiring instead of silently
723
+ // queuing a row that the dispatcher will never advance.
724
+ var provider = await _resolveProvider(countryCode);
725
+ if (!provider) {
726
+ var pErr = new Error("smsDispatcher.enqueue: no active provider covers " + countryCode);
727
+ pErr.code = "NO_PROVIDER";
728
+ throw pErr;
729
+ }
730
+
731
+ var id = _b().uuid.v7();
732
+ await query(
733
+ "INSERT INTO sms_messages " +
734
+ "(id, recipient_phone_hash, country_code, kind, body, status, " +
735
+ " provider_slug, provider_ref, attempts, next_retry_at, failure_reason, " +
736
+ " schedule_at, sent_at, delivered_at, failed_at, customer_id, created_at) " +
737
+ "VALUES (?1, ?2, ?3, ?4, ?5, 'queued', " +
738
+ " ?6, NULL, 0, NULL, NULL, ?7, NULL, NULL, NULL, ?8, ?9)",
739
+ [id, phoneHash, countryCode, kind, body, provider.slug, scheduleAt, customerId, now],
740
+ );
741
+ return await _getMessage(id);
742
+ },
743
+
744
+ markDelivered: async function (input) {
745
+ if (!input || typeof input !== "object") {
746
+ throw new TypeError("smsDispatcher.markDelivered: input object required");
747
+ }
748
+ var messageId = _uuid(input.message_id, "message_id");
749
+ var providerRef = _providerRef(input.provider_ref);
750
+ var now = _now();
751
+
752
+ var existing = await _getMessage(messageId);
753
+ if (!existing) {
754
+ var nfErr = new Error("smsDispatcher.markDelivered: message " + messageId + " not found");
755
+ nfErr.code = "MESSAGE_NOT_FOUND";
756
+ throw nfErr;
757
+ }
758
+ if (existing.status === "delivered") {
759
+ // Idempotent — provider may deliver the receipt twice.
760
+ return existing;
761
+ }
762
+ // Transitions to `delivered` are allowed from `sent` (normal
763
+ // path) and from `queued` (operator's send hook stamps
764
+ // delivered straight away for a synchronous provider).
765
+ // Terminal `failed` rows refuse the transition — the operator
766
+ // sees the contradiction explicitly instead of having a failed
767
+ // row silently flip back to success.
768
+ if (existing.status === "failed") {
769
+ var fsmErr = new Error(
770
+ "smsDispatcher.markDelivered: refusing delivered transition from terminal failed"
771
+ );
772
+ fsmErr.code = "FSM_TERMINAL";
773
+ throw fsmErr;
774
+ }
775
+ await query(
776
+ "UPDATE sms_messages SET status = 'delivered', provider_ref = ?1, " +
777
+ "delivered_at = ?2, sent_at = COALESCE(sent_at, ?2) " +
778
+ "WHERE id = ?3",
779
+ [providerRef, now, messageId],
780
+ );
781
+ return await _getMessage(messageId);
782
+ },
783
+
784
+ markFailed: async function (input) {
785
+ if (!input || typeof input !== "object") {
786
+ throw new TypeError("smsDispatcher.markFailed: input object required");
787
+ }
788
+ var messageId = _uuid(input.message_id, "message_id");
789
+ var reason = _reason(input.reason, "reason");
790
+ var retry = !!input.retry;
791
+ var now = _now();
792
+
793
+ var existing = await _getMessage(messageId);
794
+ if (!existing) {
795
+ var nfErr = new Error("smsDispatcher.markFailed: message " + messageId + " not found");
796
+ nfErr.code = "MESSAGE_NOT_FOUND";
797
+ throw nfErr;
798
+ }
799
+ if (existing.status === "delivered") {
800
+ var fsmErr = new Error(
801
+ "smsDispatcher.markFailed: refusing failed transition from terminal delivered"
802
+ );
803
+ fsmErr.code = "FSM_TERMINAL";
804
+ throw fsmErr;
805
+ }
806
+
807
+ var attempts = Number(existing.attempts || 0) + 1;
808
+ if (retry && attempts < RETRY_BACKOFF_MS.length) {
809
+ // Re-queue with back-off. Pick the entry matching the
810
+ // post-increment attempt index so the first failure waits
811
+ // RETRY_BACKOFF_MS[1] (60s isn't enough for some carriers
812
+ // — the schedule starts at 1m and ramps to 12h).
813
+ var nextRetryAt = now + RETRY_BACKOFF_MS[attempts - 1];
814
+ await query(
815
+ "UPDATE sms_messages SET status = 'queued', attempts = ?1, " +
816
+ "next_retry_at = ?2, schedule_at = ?2, failure_reason = ?3 " +
817
+ "WHERE id = ?4",
818
+ [attempts, nextRetryAt, reason, messageId],
819
+ );
820
+ return await _getMessage(messageId);
821
+ }
822
+ // Terminal failure — exhausted retry budget OR caller asked for
823
+ // a hard fail. Stamp failed_at + persist the reason.
824
+ await query(
825
+ "UPDATE sms_messages SET status = 'failed', attempts = ?1, " +
826
+ "next_retry_at = NULL, failure_reason = ?2, failed_at = ?3 " +
827
+ "WHERE id = ?4",
828
+ [attempts, reason, now, messageId],
829
+ );
830
+ return await _getMessage(messageId);
831
+ },
832
+
833
+ // Scheduler-callable. Pulls every queued message whose
834
+ // schedule_at is due, flips status to `sent`, and returns the
835
+ // updated rows. The operator's send hook (composed at the route
836
+ // layer) is responsible for the actual provider POST + the
837
+ // subsequent markDelivered / markFailed; this surface only
838
+ // advances the FSM out of `queued`. A null `now` defaults to
839
+ // Date.now() but the explicit form keeps tests deterministic.
840
+ dispatchTick: async function (input) {
841
+ input = input || {};
842
+ var now = input.now == null ? _now() : _epochMs(input.now, "now");
843
+ var batchSize = _batchSize(input.batch_size);
844
+
845
+ var due = await query(
846
+ "SELECT * FROM sms_messages " +
847
+ "WHERE status = 'queued' AND schedule_at <= ?1 " +
848
+ "ORDER BY schedule_at ASC, id ASC LIMIT ?2",
849
+ [now, batchSize],
850
+ );
851
+
852
+ var advanced = [];
853
+ for (var i = 0; i < due.rows.length; i += 1) {
854
+ var row = due.rows[i];
855
+ // The provider_slug stamp at enqueue is the routing
856
+ // decision; resolve it again here only if the row has no
857
+ // provider yet (STOP confirmation queued without a country
858
+ // hint). A missing provider at tick time terminates the row
859
+ // as failed — operators can't dispatch a message that has
860
+ // no egress.
861
+ var providerSlug = row.provider_slug;
862
+ if (!providerSlug) {
863
+ var resolved = await _resolveProvider(row.country_code);
864
+ if (resolved) providerSlug = resolved.slug;
865
+ }
866
+ if (!providerSlug) {
867
+ await query(
868
+ "UPDATE sms_messages SET status = 'failed', failure_reason = ?1, " +
869
+ "failed_at = ?2 WHERE id = ?3",
870
+ ["no_provider_at_dispatch", now, row.id],
871
+ );
872
+ advanced.push(await _getMessage(row.id));
873
+ continue;
874
+ }
875
+ await query(
876
+ "UPDATE sms_messages SET status = 'sent', provider_slug = ?1, " +
877
+ "sent_at = ?2 WHERE id = ?3",
878
+ [providerSlug, now, row.id],
879
+ );
880
+ advanced.push(await _getMessage(row.id));
881
+ }
882
+ return advanced;
883
+ },
884
+
885
+ messagesForCustomer: async function (input) {
886
+ if (!input || typeof input !== "object") {
887
+ throw new TypeError("smsDispatcher.messagesForCustomer: input object required");
888
+ }
889
+ var customerId = _uuid(input.customer_id, "customer_id");
890
+ var limit = _limit(input.limit, "limit");
891
+ var cursor = _optionalEpochMs(input.cursor, "cursor");
892
+
893
+ var sql, params;
894
+ if (cursor == null) {
895
+ sql = "SELECT * FROM sms_messages WHERE customer_id = ?1 " +
896
+ "ORDER BY created_at DESC, id DESC LIMIT ?2";
897
+ params = [customerId, limit];
898
+ } else {
899
+ sql = "SELECT * FROM sms_messages WHERE customer_id = ?1 AND created_at < ?2 " +
900
+ "ORDER BY created_at DESC, id DESC LIMIT ?3";
901
+ params = [customerId, cursor, limit];
902
+ }
903
+ var r = await query(sql, params);
904
+ var nextCursor = null;
905
+ if (r.rows.length === limit) {
906
+ nextCursor = Number(r.rows[r.rows.length - 1].created_at);
907
+ }
908
+ return { rows: r.rows, next_cursor: nextCursor };
909
+ },
910
+
911
+ // Operator lookup by provider reference — used when the
912
+ // provider's delivery-receipt webhook arrives and the operator's
913
+ // hook needs to find the matching local row before calling
914
+ // markDelivered / markFailed.
915
+ messageByProviderRef: async function (input) {
916
+ if (!input || typeof input !== "object") {
917
+ throw new TypeError("smsDispatcher.messageByProviderRef: input object required");
918
+ }
919
+ var providerSlug = _slug(input.provider_slug, "provider_slug");
920
+ var providerRef = _providerRef(input.provider_ref);
921
+ var r = await query(
922
+ "SELECT * FROM sms_messages WHERE provider_slug = ?1 AND provider_ref = ?2 LIMIT 1",
923
+ [providerSlug, providerRef],
924
+ );
925
+ return r.rows[0] || null;
926
+ },
927
+ };
928
+ }
929
+
930
+ module.exports = {
931
+ create: create,
932
+ CHANNELS: CHANNELS.slice(),
933
+ STATES: STATES.slice(),
934
+ KINDS: KINDS.slice(),
935
+ STATUSES: STATUSES.slice(),
936
+ STOP_KEYWORDS: STOP_KEYWORDS.slice(),
937
+ RETRY_BACKOFF_MS: RETRY_BACKOFF_MS.slice(),
938
+ PHONE_NAMESPACE: PHONE_NAMESPACE,
939
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
940
+ MAX_BODY_LEN: MAX_BODY_LEN,
941
+ MAX_LIST_LIMIT: MAX_LIST_LIMIT,
942
+ DEFAULT_LIST_LIMIT: DEFAULT_LIST_LIMIT,
943
+ MAX_BATCH_SIZE: MAX_BATCH_SIZE,
944
+ DEFAULT_BATCH_SIZE: DEFAULT_BATCH_SIZE,
945
+ };