@blamejs/blamejs-shop 0.0.65 → 0.0.70

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/lib/assembly-instructions.js +777 -0
  3. package/lib/auto-replenish.js +933 -0
  4. package/lib/business-hours.js +980 -0
  5. package/lib/click-and-collect.js +711 -0
  6. package/lib/clickstream.js +713 -0
  7. package/lib/cost-layers.js +774 -0
  8. package/lib/credit-limits.js +752 -0
  9. package/lib/currency-rounding.js +525 -0
  10. package/lib/customer-activity.js +862 -0
  11. package/lib/customer-notes.js +712 -0
  12. package/lib/customer-risk-profile.js +593 -0
  13. package/lib/customer-surveys.js +1012 -0
  14. package/lib/damage-photos.js +473 -0
  15. package/lib/discount-allocation.js +557 -0
  16. package/lib/dropship-forwarding.js +645 -0
  17. package/lib/email-templates.js +817 -0
  18. package/lib/index.js +45 -0
  19. package/lib/inventory-allocations.js +559 -0
  20. package/lib/inventory-writeoffs.js +636 -0
  21. package/lib/knowledge-base.js +1104 -0
  22. package/lib/locale-router.js +1077 -0
  23. package/lib/operator-roles.js +768 -0
  24. package/lib/order-escalation.js +951 -0
  25. package/lib/order-ratings.js +495 -0
  26. package/lib/order-tags.js +944 -0
  27. package/lib/packing-slips.js +810 -0
  28. package/lib/payment-retries.js +816 -0
  29. package/lib/pick-lists.js +639 -0
  30. package/lib/pixel-events.js +995 -0
  31. package/lib/preorder.js +595 -0
  32. package/lib/print-queue.js +681 -0
  33. package/lib/product-qa.js +749 -0
  34. package/lib/promo-bundles.js +835 -0
  35. package/lib/push-notifications.js +937 -0
  36. package/lib/refund-automation.js +853 -0
  37. package/lib/reorder-reminders.js +798 -0
  38. package/lib/robots-config.js +753 -0
  39. package/lib/seller-signup.js +1052 -0
  40. package/lib/site-redirects.js +690 -0
  41. package/lib/sitemap-generator.js +717 -0
  42. package/lib/subscription-gifts.js +710 -0
  43. package/lib/tax-cert-renewals.js +632 -0
  44. package/lib/theme-assets.js +711 -0
  45. package/lib/tier-benefits.js +776 -0
  46. package/lib/vendor/MANIFEST.json +2 -2
  47. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  48. package/lib/vendor/blamejs/api-snapshot.json +2 -2
  49. package/lib/vendor/blamejs/lib/metrics.js +68 -4
  50. package/lib/vendor/blamejs/package.json +1 -1
  51. package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
  52. package/lib/wishlist-alerts.js +842 -0
  53. package/lib/wishlist-sharing.js +718 -0
  54. package/package.json +1 -1
@@ -0,0 +1,937 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.pushNotifications
4
+ * @title Push notifications — outbound mobile + web push layer
5
+ *
6
+ * @intro
7
+ * Outbound push-notification layer. Operators register one provider
8
+ * per egress class (APNs for iOS, FCM for Android, VAPID for web
9
+ * push), register per-customer device tokens against a provider,
10
+ * and queue notifications that the dispatcher tick hands to the
11
+ * operator-wired send hook. The HTTP push itself does not happen
12
+ * in this primitive — the operator composes the dispatcher with
13
+ * the provider's egress at the route layer; this surface only
14
+ * owns the registry, the consent matrix, and the delivery FSM.
15
+ *
16
+ * Channel consent is opt-in per customer per channel
17
+ * (`marketing` / `transactional` / `alert`). Marketing pushes
18
+ * refuse without an explicit opt-in row; transactional + alert
19
+ * pushes refuse only on an explicit opt-out for the matching
20
+ * channel.
21
+ *
22
+ * The shape:
23
+ *
24
+ * var push = bShop.pushNotifications.create({ query: q });
25
+ *
26
+ * await push.registerProvider({
27
+ * slug: "apns-prod",
28
+ * kind: "apns",
29
+ * credentials: "-----BEGIN PRIVATE KEY-----\\n...",
30
+ * active: true,
31
+ * });
32
+ *
33
+ * await push.registerDevice({
34
+ * customer_id: customerId,
35
+ * provider_slug: "apns-prod",
36
+ * device_token: "a1b2c3d4...",
37
+ * device_class: "ios",
38
+ * locale: "en-US",
39
+ * });
40
+ *
41
+ * await push.recordOptIn({ customer_id: customerId, channel: "marketing" });
42
+ *
43
+ * await push.enqueueNotification({
44
+ * recipient_customer_id: customerId,
45
+ * channel: "transactional",
46
+ * title: "Order shipped",
47
+ * body: "Your order #1234 is on the way.",
48
+ * payload: { order_id: "1234" },
49
+ * });
50
+ *
51
+ * // Scheduler tick — operator wires this to a cron / Workers
52
+ * // Cron Trigger. Pulls every queued notification whose
53
+ * // schedule_at is due, hands it to the operator-wired send hook,
54
+ * // and flips status to `sent`. The send hook stamps
55
+ * // provider_message_id via `markDelivered` when the provider's
56
+ * // acknowledgement arrives; transient failures flow through
57
+ * // `markFailed` with `retry: true` so the row re-queues with
58
+ * // back-off.
59
+ * await push.dispatchTick({ now: Date.now() });
60
+ *
61
+ * FSM:
62
+ * queued → sent → delivered
63
+ * ↘ failed (retry → queued | terminal)
64
+ *
65
+ * Composition: zero npm runtime deps; the primitive composes
66
+ * blamejs (`b.uuid.v7`, `b.crypto.namespaceHash`, `b.guardUuid`).
67
+ *
68
+ * @related b.crypto.namespaceHash, b.uuid, b.guardUuid
69
+ */
70
+
71
+ var bShop;
72
+ function _b() {
73
+ if (!bShop) bShop = require("./index");
74
+ return bShop.framework;
75
+ }
76
+
77
+ // ---- constants ----------------------------------------------------------
78
+
79
+ var DEVICE_TOKEN_NAMESPACE = "push-device-token";
80
+ var CREDENTIALS_NAMESPACE = "push-provider-credentials";
81
+
82
+ var KINDS = ["apns", "fcm", "web_push"];
83
+ var DEVICE_CLASSES = ["ios", "android", "web", "desktop"];
84
+ var CHANNELS = ["marketing", "transactional", "alert"];
85
+ var STATES = ["opt_in", "opt_out"];
86
+ var STATUSES = ["queued", "sent", "delivered", "failed"];
87
+
88
+ var MAX_SLUG_LEN = 64;
89
+ var MAX_TITLE_LEN = 256;
90
+ var MAX_BODY_LEN = 4096;
91
+ var MIN_TITLE_LEN = 1;
92
+ var MIN_BODY_LEN = 1;
93
+ var MAX_REASON_LEN = 280;
94
+ var MAX_TOKEN_LEN = 4096;
95
+ var MIN_TOKEN_LEN = 8;
96
+ var MAX_LOCALE_LEN = 35;
97
+ var MAX_CREDENTIALS_LEN = 32 * 1024; // 32 KiB upper bound on credential material
98
+ var MIN_CREDENTIALS_LEN = 16;
99
+ var MAX_PAYLOAD_BYTES = 16 * 1024; // 16 KiB serialised JSON payload
100
+ var MAX_LIST_LIMIT = 500;
101
+ var DEFAULT_LIST_LIMIT = 100;
102
+ var MAX_BATCH_SIZE = 1000;
103
+ var DEFAULT_BATCH_SIZE = 100;
104
+ var MAX_PROV_MSG_ID_LEN = 256;
105
+
106
+ // Transient-failure retry back-off — 1m, 5m, 30m, 2h, 12h. The
107
+ // dispatch tick consults the schedule on `markFailed({ retry: true })`
108
+ // to compute `next_retry_at`; once the schedule is exhausted the row
109
+ // terminates as failed regardless of the caller's `retry` request.
110
+ var RETRY_BACKOFF_MS = [
111
+ 60 * 1000,
112
+ 5 * 60 * 1000,
113
+ 30 * 60 * 1000,
114
+ 2 * 60 * 60 * 1000,
115
+ 12 * 60 * 60 * 1000,
116
+ ];
117
+
118
+ var SLUG_RE = /^[a-z](?:[a-z0-9-]*[a-z0-9])?$/;
119
+ var LOCALE_RE = /^[A-Za-z]{2,8}(?:[-_][A-Za-z0-9]{2,8}){0,3}$/;
120
+ var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
121
+ var ZERO_WIDTH_RE = new RegExp(
122
+ "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
123
+ );
124
+
125
+ // ---- validators ---------------------------------------------------------
126
+
127
+ function _slug(s, label) {
128
+ if (typeof s !== "string" || !s.length) {
129
+ throw new TypeError("pushNotifications: " + label + " must be a non-empty string");
130
+ }
131
+ if (s.length > MAX_SLUG_LEN) {
132
+ throw new TypeError("pushNotifications: " + label + " must be <= " + MAX_SLUG_LEN + " characters");
133
+ }
134
+ if (!SLUG_RE.test(s)) {
135
+ throw new TypeError("pushNotifications: " + label + " must match /[a-z][a-z0-9-]*[a-z0-9]/");
136
+ }
137
+ return s;
138
+ }
139
+
140
+ function _kind(s) {
141
+ if (typeof s !== "string" || KINDS.indexOf(s) === -1) {
142
+ throw new TypeError("pushNotifications: kind must be one of " + KINDS.join(", "));
143
+ }
144
+ return s;
145
+ }
146
+
147
+ function _deviceClass(s) {
148
+ if (typeof s !== "string" || DEVICE_CLASSES.indexOf(s) === -1) {
149
+ throw new TypeError("pushNotifications: device_class must be one of " + DEVICE_CLASSES.join(", "));
150
+ }
151
+ return s;
152
+ }
153
+
154
+ function _channel(s) {
155
+ if (typeof s !== "string" || CHANNELS.indexOf(s) === -1) {
156
+ throw new TypeError("pushNotifications: channel must be one of " + CHANNELS.join(", "));
157
+ }
158
+ return s;
159
+ }
160
+
161
+ function _credentials(s) {
162
+ if (typeof s !== "string" || s.length < MIN_CREDENTIALS_LEN) {
163
+ throw new TypeError("pushNotifications: credentials must be a string >= " + MIN_CREDENTIALS_LEN + " characters");
164
+ }
165
+ if (s.length > MAX_CREDENTIALS_LEN) {
166
+ throw new TypeError("pushNotifications: credentials must be <= " + MAX_CREDENTIALS_LEN + " characters");
167
+ }
168
+ if (CONTROL_BYTE_RE.test(s)) {
169
+ throw new TypeError("pushNotifications: credentials contains control bytes");
170
+ }
171
+ if (ZERO_WIDTH_RE.test(s)) {
172
+ throw new TypeError("pushNotifications: credentials contains zero-width / bidi-override codepoints");
173
+ }
174
+ return s;
175
+ }
176
+
177
+ function _deviceToken(s) {
178
+ if (typeof s !== "string" || s.length < MIN_TOKEN_LEN) {
179
+ throw new TypeError("pushNotifications: device_token must be a string >= " + MIN_TOKEN_LEN + " characters");
180
+ }
181
+ if (s.length > MAX_TOKEN_LEN) {
182
+ throw new TypeError("pushNotifications: device_token must be <= " + MAX_TOKEN_LEN + " characters");
183
+ }
184
+ if (CONTROL_BYTE_RE.test(s)) {
185
+ throw new TypeError("pushNotifications: device_token contains control bytes");
186
+ }
187
+ if (ZERO_WIDTH_RE.test(s)) {
188
+ throw new TypeError("pushNotifications: device_token contains zero-width / bidi-override codepoints");
189
+ }
190
+ return s;
191
+ }
192
+
193
+ function _locale(s) {
194
+ if (s == null) return null;
195
+ if (typeof s !== "string" || !s.length) {
196
+ throw new TypeError("pushNotifications: locale must be a non-empty string when provided");
197
+ }
198
+ if (s.length > MAX_LOCALE_LEN) {
199
+ throw new TypeError("pushNotifications: locale must be <= " + MAX_LOCALE_LEN + " characters");
200
+ }
201
+ if (!LOCALE_RE.test(s)) {
202
+ throw new TypeError("pushNotifications: locale must look like a BCP-47 tag (e.g. en-US)");
203
+ }
204
+ return s;
205
+ }
206
+
207
+ function _title(s) {
208
+ if (typeof s !== "string" || s.length < MIN_TITLE_LEN) {
209
+ throw new TypeError("pushNotifications: title must be a non-empty string");
210
+ }
211
+ if (s.length > MAX_TITLE_LEN) {
212
+ throw new TypeError("pushNotifications: title must be <= " + MAX_TITLE_LEN + " characters");
213
+ }
214
+ if (CONTROL_BYTE_RE.test(s)) {
215
+ throw new TypeError("pushNotifications: title contains control bytes");
216
+ }
217
+ if (ZERO_WIDTH_RE.test(s)) {
218
+ throw new TypeError("pushNotifications: title contains zero-width / bidi-override codepoints");
219
+ }
220
+ return s;
221
+ }
222
+
223
+ function _body(s) {
224
+ if (typeof s !== "string" || s.length < MIN_BODY_LEN) {
225
+ throw new TypeError("pushNotifications: body must be a non-empty string");
226
+ }
227
+ if (s.length > MAX_BODY_LEN) {
228
+ throw new TypeError("pushNotifications: body must be <= " + MAX_BODY_LEN + " characters");
229
+ }
230
+ if (CONTROL_BYTE_RE.test(s)) {
231
+ throw new TypeError("pushNotifications: body contains control bytes");
232
+ }
233
+ if (ZERO_WIDTH_RE.test(s)) {
234
+ throw new TypeError("pushNotifications: body contains zero-width / bidi-override codepoints");
235
+ }
236
+ return s;
237
+ }
238
+
239
+ function _payload(p) {
240
+ if (p == null) return null;
241
+ if (typeof p !== "object" || Array.isArray(p)) {
242
+ throw new TypeError("pushNotifications: payload must be a plain object when provided");
243
+ }
244
+ var json;
245
+ try { json = JSON.stringify(p); }
246
+ catch (e) {
247
+ throw new TypeError("pushNotifications: payload is not JSON-serialisable: " + (e && e.message || e));
248
+ }
249
+ if (typeof json !== "string") {
250
+ throw new TypeError("pushNotifications: payload did not serialise to a string");
251
+ }
252
+ if (Buffer.byteLength(json, "utf8") > MAX_PAYLOAD_BYTES) {
253
+ throw new TypeError("pushNotifications: payload exceeds " + MAX_PAYLOAD_BYTES + " bytes serialised");
254
+ }
255
+ return json;
256
+ }
257
+
258
+ function _uuid(s, label) {
259
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
260
+ catch (e) {
261
+ throw new TypeError("pushNotifications: " + label + " — " + (e && e.message || "invalid UUID"));
262
+ }
263
+ }
264
+
265
+ function _reason(s, label) {
266
+ if (s == null) return null;
267
+ if (typeof s !== "string" || !s.length) {
268
+ throw new TypeError("pushNotifications: " + label + " must be a non-empty string when provided");
269
+ }
270
+ if (s.length > MAX_REASON_LEN) {
271
+ throw new TypeError("pushNotifications: " + label + " must be <= " + MAX_REASON_LEN + " characters");
272
+ }
273
+ if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
274
+ throw new TypeError("pushNotifications: " + label + " contains control / zero-width bytes");
275
+ }
276
+ return s;
277
+ }
278
+
279
+ function _providerMessageId(s) {
280
+ if (typeof s !== "string" || !s.length) {
281
+ throw new TypeError("pushNotifications: provider_message_id must be a non-empty string");
282
+ }
283
+ if (s.length > MAX_PROV_MSG_ID_LEN) {
284
+ throw new TypeError("pushNotifications: provider_message_id must be <= " + MAX_PROV_MSG_ID_LEN + " characters");
285
+ }
286
+ if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
287
+ throw new TypeError("pushNotifications: provider_message_id contains control / zero-width bytes");
288
+ }
289
+ return s;
290
+ }
291
+
292
+ function _epochMs(n, label) {
293
+ if (!Number.isInteger(n) || n <= 0) {
294
+ throw new TypeError("pushNotifications: " + label + " must be a positive integer (epoch ms)");
295
+ }
296
+ return n;
297
+ }
298
+
299
+ function _optionalEpochMs(n, label) {
300
+ if (n == null) return null;
301
+ return _epochMs(n, label);
302
+ }
303
+
304
+ function _limit(n, label) {
305
+ if (n == null) return DEFAULT_LIST_LIMIT;
306
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
307
+ throw new TypeError("pushNotifications: " + label + " must be an integer in [1, " + MAX_LIST_LIMIT + "]");
308
+ }
309
+ return n;
310
+ }
311
+
312
+ function _batchSize(n) {
313
+ if (n == null) return DEFAULT_BATCH_SIZE;
314
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_BATCH_SIZE) {
315
+ throw new TypeError("pushNotifications: batch_size must be an integer in [1, " + MAX_BATCH_SIZE + "]");
316
+ }
317
+ return n;
318
+ }
319
+
320
+ // Monotonic clock. Date.now() can stall on the same millisecond when
321
+ // the caller writes a tight loop of rows; the ratchet bumps every
322
+ // repeat by +1ms so each row gets a unique created_at / occurred_at
323
+ // and the per-customer / provider-message-id ordering reads cleanly.
324
+ var _lastTs = 0;
325
+ function _now() {
326
+ var t = Date.now();
327
+ if (t <= _lastTs) { t = _lastTs + 1; }
328
+ _lastTs = t;
329
+ return t;
330
+ }
331
+
332
+ // ---- factory ------------------------------------------------------------
333
+
334
+ function create(opts) {
335
+ opts = opts || {};
336
+ var query = opts.query;
337
+ if (!query) {
338
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
339
+ }
340
+
341
+ // -------- internal helpers --------
342
+
343
+ function hashDeviceToken(tokenNormalised) {
344
+ _deviceToken(tokenNormalised);
345
+ return _b().crypto.namespaceHash(DEVICE_TOKEN_NAMESPACE, tokenNormalised);
346
+ }
347
+
348
+ function hashCredentials(credentialsNormalised) {
349
+ _credentials(credentialsNormalised);
350
+ return _b().crypto.namespaceHash(CREDENTIALS_NAMESPACE, credentialsNormalised);
351
+ }
352
+
353
+ async function _getProvider(slug) {
354
+ var r = await query("SELECT * FROM push_providers WHERE slug = ?1", [slug]);
355
+ return r.rows[0] || null;
356
+ }
357
+
358
+ async function _getNotification(id) {
359
+ var r = await query("SELECT * FROM push_notifications WHERE id = ?1", [id]);
360
+ return r.rows[0] || null;
361
+ }
362
+
363
+ async function _getDevice(id) {
364
+ var r = await query("SELECT * FROM push_devices WHERE id = ?1", [id]);
365
+ return r.rows[0] || null;
366
+ }
367
+
368
+ async function _getDeviceByTokenHash(tokenHash) {
369
+ var r = await query("SELECT * FROM push_devices WHERE device_token_hash = ?1", [tokenHash]);
370
+ return r.rows[0] || null;
371
+ }
372
+
373
+ async function _getOptState(customerId, channel) {
374
+ var r = await query(
375
+ "SELECT * FROM push_opt_state WHERE customer_id = ?1 AND channel = ?2",
376
+ [customerId, channel],
377
+ );
378
+ return r.rows[0] || null;
379
+ }
380
+
381
+ // -------- public surface --------
382
+
383
+ return {
384
+ // Surface the constants the operator's authoring code consults.
385
+ KINDS: KINDS.slice(),
386
+ DEVICE_CLASSES: DEVICE_CLASSES.slice(),
387
+ CHANNELS: CHANNELS.slice(),
388
+ STATES: STATES.slice(),
389
+ STATUSES: STATUSES.slice(),
390
+ RETRY_BACKOFF_MS: RETRY_BACKOFF_MS.slice(),
391
+ DEVICE_TOKEN_NAMESPACE: DEVICE_TOKEN_NAMESPACE,
392
+ CREDENTIALS_NAMESPACE: CREDENTIALS_NAMESPACE,
393
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
394
+ MAX_TITLE_LEN: MAX_TITLE_LEN,
395
+ MAX_BODY_LEN: MAX_BODY_LEN,
396
+ MAX_PAYLOAD_BYTES: MAX_PAYLOAD_BYTES,
397
+ MAX_LIST_LIMIT: MAX_LIST_LIMIT,
398
+ DEFAULT_LIST_LIMIT: DEFAULT_LIST_LIMIT,
399
+ MAX_BATCH_SIZE: MAX_BATCH_SIZE,
400
+ DEFAULT_BATCH_SIZE: DEFAULT_BATCH_SIZE,
401
+
402
+ // Helpers surfaced so callers compose with the same hash function
403
+ // the primitive uses internally.
404
+ hashDeviceToken: hashDeviceToken,
405
+ hashCredentials: hashCredentials,
406
+
407
+ registerProvider: async function (input) {
408
+ if (!input || typeof input !== "object") {
409
+ throw new TypeError("pushNotifications.registerProvider: input object required");
410
+ }
411
+ var slug = _slug(input.slug, "slug");
412
+ var kind = _kind(input.kind);
413
+ _credentials(input.credentials);
414
+ var credentialsNormalised = input.credentials;
415
+ var credentialsHash = _b().crypto.namespaceHash(CREDENTIALS_NAMESPACE, credentialsNormalised);
416
+ var active = input.active == null ? true : !!input.active;
417
+ var now = _now();
418
+
419
+ var existing = await _getProvider(slug);
420
+ if (existing) {
421
+ // Re-registering after archive un-archives — operators
422
+ // rotating provider credentials don't need a separate
423
+ // "unarchive" verb. The kind is locked at first registration:
424
+ // a slug that started as `apns` can't become `fcm` later
425
+ // without a separate slug. That refusal protects the metrics
426
+ // surface from cross-transport contamination.
427
+ if (existing.kind !== kind) {
428
+ throw new TypeError(
429
+ "pushNotifications.registerProvider: cannot change kind for existing provider '" +
430
+ slug + "' (was '" + existing.kind + "', requested '" + kind + "')"
431
+ );
432
+ }
433
+ await query(
434
+ "UPDATE push_providers SET credentials_hash = ?1, credentials_normalised = ?2, " +
435
+ "active = ?3, archived_at = NULL, updated_at = ?4 WHERE slug = ?5",
436
+ [credentialsHash, credentialsNormalised, active ? 1 : 0, now, slug],
437
+ );
438
+ } else {
439
+ await query(
440
+ "INSERT INTO push_providers " +
441
+ "(slug, kind, credentials_hash, credentials_normalised, active, " +
442
+ " archived_at, created_at, updated_at) " +
443
+ "VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6, ?6)",
444
+ [slug, kind, credentialsHash, credentialsNormalised, active ? 1 : 0, now],
445
+ );
446
+ }
447
+ return await _getProvider(slug);
448
+ },
449
+
450
+ registerDevice: async function (input) {
451
+ if (!input || typeof input !== "object") {
452
+ throw new TypeError("pushNotifications.registerDevice: input object required");
453
+ }
454
+ var customerId = _uuid(input.customer_id, "customer_id");
455
+ var providerSlug = _slug(input.provider_slug, "provider_slug");
456
+ var token = _deviceToken(input.device_token);
457
+ var deviceClass = _deviceClass(input.device_class);
458
+ var locale = _locale(input.locale);
459
+ var now = _now();
460
+ var tokenHash = _b().crypto.namespaceHash(DEVICE_TOKEN_NAMESPACE, token);
461
+
462
+ // Provider must exist + be active. Devices registered against
463
+ // an archived / inactive provider can't be dispatched against,
464
+ // so the registration refusal surfaces the wiring gap at the
465
+ // write site instead of letting the device row collect dust.
466
+ var provider = await _getProvider(providerSlug);
467
+ if (!provider) {
468
+ throw new TypeError(
469
+ "pushNotifications.registerDevice: provider '" + providerSlug + "' not registered"
470
+ );
471
+ }
472
+ if (Number(provider.active) !== 1 || provider.archived_at != null) {
473
+ throw new TypeError(
474
+ "pushNotifications.registerDevice: provider '" + providerSlug + "' is inactive / archived"
475
+ );
476
+ }
477
+
478
+ // Token-class compatibility check. iOS tokens go to APNs,
479
+ // Android tokens to FCM, web tokens to web_push. The desktop
480
+ // class can ride either FCM (Chrome desktop push) or web_push
481
+ // (Safari / Firefox), so it's allowed against both.
482
+ var compatible = (
483
+ (deviceClass === "ios" && provider.kind === "apns") ||
484
+ (deviceClass === "android" && provider.kind === "fcm") ||
485
+ (deviceClass === "web" && provider.kind === "web_push") ||
486
+ (deviceClass === "desktop" && (provider.kind === "fcm" || provider.kind === "web_push"))
487
+ );
488
+ if (!compatible) {
489
+ throw new TypeError(
490
+ "pushNotifications.registerDevice: device_class '" + deviceClass +
491
+ "' is not compatible with provider kind '" + provider.kind + "'"
492
+ );
493
+ }
494
+
495
+ // UNIQUE(device_token_hash) — re-registering the same token
496
+ // updates the existing row (customer_id may change when the app
497
+ // is reinstalled under a different account; locale + last_seen
498
+ // refresh on every heartbeat). The revocation lifts on a fresh
499
+ // registration because the operator's app has the token live
500
+ // again.
501
+ var existing = await _getDeviceByTokenHash(tokenHash);
502
+ if (existing) {
503
+ await query(
504
+ "UPDATE push_devices SET customer_id = ?1, provider_slug = ?2, " +
505
+ "device_token_normalised = ?3, device_class = ?4, locale = ?5, " +
506
+ "revoked_at = NULL, revoke_reason = NULL, last_seen_at = ?6 " +
507
+ "WHERE device_token_hash = ?7",
508
+ [customerId, providerSlug, token, deviceClass, locale, now, tokenHash],
509
+ );
510
+ return await _getDeviceByTokenHash(tokenHash);
511
+ }
512
+ var id = _b().uuid.v7();
513
+ await query(
514
+ "INSERT INTO push_devices " +
515
+ "(id, customer_id, provider_slug, device_token_hash, " +
516
+ " device_token_normalised, device_class, locale, " +
517
+ " revoked_at, revoke_reason, last_seen_at, created_at) " +
518
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, NULL, ?8, ?8)",
519
+ [id, customerId, providerSlug, tokenHash, token, deviceClass, locale, now],
520
+ );
521
+ return await _getDevice(id);
522
+ },
523
+
524
+ revokeDevice: async function (input) {
525
+ if (!input || typeof input !== "object") {
526
+ throw new TypeError("pushNotifications.revokeDevice: input object required");
527
+ }
528
+ var deviceId = _uuid(input.device_id, "device_id");
529
+ var reason = _reason(input.reason, "reason");
530
+ var now = _now();
531
+
532
+ var existing = await _getDevice(deviceId);
533
+ if (!existing) {
534
+ var nfErr = new Error("pushNotifications.revokeDevice: device " + deviceId + " not found");
535
+ nfErr.code = "DEVICE_NOT_FOUND";
536
+ throw nfErr;
537
+ }
538
+ // Idempotent — a second revoke refreshes the reason but
539
+ // doesn't bump `revoked_at` (the first revocation is the one
540
+ // operators reconcile against).
541
+ if (existing.revoked_at != null) {
542
+ if (reason) {
543
+ await query(
544
+ "UPDATE push_devices SET revoke_reason = ?1 WHERE id = ?2",
545
+ [reason, deviceId],
546
+ );
547
+ }
548
+ return await _getDevice(deviceId);
549
+ }
550
+ await query(
551
+ "UPDATE push_devices SET revoked_at = ?1, revoke_reason = ?2 WHERE id = ?3",
552
+ [now, reason, deviceId],
553
+ );
554
+ return await _getDevice(deviceId);
555
+ },
556
+
557
+ recordOptIn: async function (input) {
558
+ if (!input || typeof input !== "object") {
559
+ throw new TypeError("pushNotifications.recordOptIn: input object required");
560
+ }
561
+ var customerId = _uuid(input.customer_id, "customer_id");
562
+ var channel = _channel(input.channel);
563
+ var now = _now();
564
+
565
+ var existing = await _getOptState(customerId, channel);
566
+ if (existing) {
567
+ await query(
568
+ "UPDATE push_opt_state SET state = 'opt_in', reason = NULL, occurred_at = ?1 " +
569
+ "WHERE customer_id = ?2 AND channel = ?3",
570
+ [now, customerId, channel],
571
+ );
572
+ return await _getOptState(customerId, channel);
573
+ }
574
+ var id = _b().uuid.v7();
575
+ await query(
576
+ "INSERT INTO push_opt_state " +
577
+ "(id, customer_id, channel, state, reason, occurred_at) " +
578
+ "VALUES (?1, ?2, ?3, 'opt_in', NULL, ?4)",
579
+ [id, customerId, channel, now],
580
+ );
581
+ return await _getOptState(customerId, channel);
582
+ },
583
+
584
+ recordOptOut: async function (input) {
585
+ if (!input || typeof input !== "object") {
586
+ throw new TypeError("pushNotifications.recordOptOut: input object required");
587
+ }
588
+ var customerId = _uuid(input.customer_id, "customer_id");
589
+ var channel = input.channel == null ? null : _channel(input.channel);
590
+ var reason = _reason(input.reason, "reason");
591
+ var now = _now();
592
+
593
+ // Operator-supplied channel narrows the opt-out to one
594
+ // channel. Absence → opt-out applies to every channel; a
595
+ // single call captures the global "user disabled push
596
+ // notifications" toggle.
597
+ var channels = channel ? [channel] : CHANNELS.slice();
598
+ var written = [];
599
+ for (var i = 0; i < channels.length; i += 1) {
600
+ var ch = channels[i];
601
+ var existing = await _getOptState(customerId, ch);
602
+ if (existing) {
603
+ await query(
604
+ "UPDATE push_opt_state SET state = 'opt_out', reason = ?1, occurred_at = ?2 " +
605
+ "WHERE customer_id = ?3 AND channel = ?4",
606
+ [reason, now, customerId, ch],
607
+ );
608
+ } else {
609
+ var id = _b().uuid.v7();
610
+ await query(
611
+ "INSERT INTO push_opt_state " +
612
+ "(id, customer_id, channel, state, reason, occurred_at) " +
613
+ "VALUES (?1, ?2, ?3, 'opt_out', ?4, ?5)",
614
+ [id, customerId, ch, reason, now],
615
+ );
616
+ }
617
+ written.push(await _getOptState(customerId, ch));
618
+ }
619
+ return written;
620
+ },
621
+
622
+ isOptedIn: async function (input) {
623
+ if (!input || typeof input !== "object") {
624
+ throw new TypeError("pushNotifications.isOptedIn: input object required");
625
+ }
626
+ var customerId = _uuid(input.customer_id, "customer_id");
627
+ var channel = input.channel == null ? null : _channel(input.channel);
628
+
629
+ if (channel == null) {
630
+ var r = await query(
631
+ "SELECT state FROM push_opt_state " +
632
+ "WHERE customer_id = ?1 AND state = 'opt_in' LIMIT 1",
633
+ [customerId],
634
+ );
635
+ return r.rows.length > 0;
636
+ }
637
+ var row = await _getOptState(customerId, channel);
638
+ return !!(row && row.state === "opt_in");
639
+ },
640
+
641
+ enqueueNotification: async function (input) {
642
+ if (!input || typeof input !== "object") {
643
+ throw new TypeError("pushNotifications.enqueueNotification: input object required");
644
+ }
645
+ var customerId = _uuid(input.recipient_customer_id, "recipient_customer_id");
646
+ var channel = _channel(input.channel);
647
+ var title = _title(input.title);
648
+ var body = _body(input.body);
649
+ var payloadJson = _payload(input.payload);
650
+ var scheduleAt = input.schedule_at == null ? _now() : _epochMs(input.schedule_at, "schedule_at");
651
+ var now = _now();
652
+
653
+ // Opt-in gate. Marketing pushes refuse without an explicit
654
+ // opt-in row (opt-IN semantics — the strictest channel).
655
+ // Transactional + alert pushes refuse only on an explicit
656
+ // opt-out for the matching channel.
657
+ if (channel === "marketing") {
658
+ var marketingRow = await _getOptState(customerId, "marketing");
659
+ if (!marketingRow || marketingRow.state !== "opt_in") {
660
+ var mErr = new Error("pushNotifications.enqueueNotification: recipient is not opted-in to marketing");
661
+ mErr.code = "NOT_OPTED_IN";
662
+ throw mErr;
663
+ }
664
+ } else {
665
+ var explicitRow = await _getOptState(customerId, channel);
666
+ if (explicitRow && explicitRow.state === "opt_out") {
667
+ var oErr = new Error("pushNotifications.enqueueNotification: recipient opted-out of " + channel);
668
+ oErr.code = "OPTED_OUT";
669
+ throw oErr;
670
+ }
671
+ }
672
+
673
+ // Recipient must have at least one non-revoked device. No
674
+ // device → no egress, no point queueing. Operators that want
675
+ // to drop the notification entirely catch the NO_DEVICE code;
676
+ // operators that want to queue ahead of registration handle
677
+ // the registration on the app side first.
678
+ var devices = await query(
679
+ "SELECT * FROM push_devices WHERE customer_id = ?1 AND revoked_at IS NULL ORDER BY last_seen_at DESC LIMIT 1",
680
+ [customerId],
681
+ );
682
+ if (devices.rows.length === 0) {
683
+ var dErr = new Error("pushNotifications.enqueueNotification: recipient has no active devices");
684
+ dErr.code = "NO_DEVICE";
685
+ throw dErr;
686
+ }
687
+ var providerSlug = devices.rows[0].provider_slug;
688
+
689
+ var id = _b().uuid.v7();
690
+ await query(
691
+ "INSERT INTO push_notifications " +
692
+ "(id, recipient_customer_id, channel, title, body, payload_json, status, " +
693
+ " provider_slug, provider_message_id, attempts, next_retry_at, fail_reason, " +
694
+ " schedule_at, dispatched_at, delivered_at, failed_at, created_at) " +
695
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'queued', " +
696
+ " ?7, NULL, 0, NULL, NULL, ?8, NULL, NULL, NULL, ?9)",
697
+ [id, customerId, channel, title, body, payloadJson, providerSlug, scheduleAt, now],
698
+ );
699
+ return await _getNotification(id);
700
+ },
701
+
702
+ markDelivered: async function (input) {
703
+ if (!input || typeof input !== "object") {
704
+ throw new TypeError("pushNotifications.markDelivered: input object required");
705
+ }
706
+ var notificationId = _uuid(input.notification_id, "notification_id");
707
+ var providerMessageId = _providerMessageId(input.provider_message_id);
708
+ var now = _now();
709
+
710
+ var existing = await _getNotification(notificationId);
711
+ if (!existing) {
712
+ var nfErr = new Error("pushNotifications.markDelivered: notification " + notificationId + " not found");
713
+ nfErr.code = "NOTIFICATION_NOT_FOUND";
714
+ throw nfErr;
715
+ }
716
+ if (existing.status === "delivered") {
717
+ // Idempotent — the provider may deliver the receipt twice.
718
+ return existing;
719
+ }
720
+ // Terminal `failed` refuses the transition so the operator
721
+ // sees the contradiction explicitly instead of having a
722
+ // failed row silently flip back to success.
723
+ if (existing.status === "failed") {
724
+ var fsmErr = new Error(
725
+ "pushNotifications.markDelivered: refusing delivered transition from terminal failed"
726
+ );
727
+ fsmErr.code = "FSM_TERMINAL";
728
+ throw fsmErr;
729
+ }
730
+ await query(
731
+ "UPDATE push_notifications SET status = 'delivered', provider_message_id = ?1, " +
732
+ "delivered_at = ?2, dispatched_at = COALESCE(dispatched_at, ?2) " +
733
+ "WHERE id = ?3",
734
+ [providerMessageId, now, notificationId],
735
+ );
736
+ return await _getNotification(notificationId);
737
+ },
738
+
739
+ markFailed: async function (input) {
740
+ if (!input || typeof input !== "object") {
741
+ throw new TypeError("pushNotifications.markFailed: input object required");
742
+ }
743
+ var notificationId = _uuid(input.notification_id, "notification_id");
744
+ var reason = _reason(input.reason, "reason");
745
+ var retry = !!input.retry;
746
+ var now = _now();
747
+
748
+ var existing = await _getNotification(notificationId);
749
+ if (!existing) {
750
+ var nfErr = new Error("pushNotifications.markFailed: notification " + notificationId + " not found");
751
+ nfErr.code = "NOTIFICATION_NOT_FOUND";
752
+ throw nfErr;
753
+ }
754
+ if (existing.status === "delivered") {
755
+ var fsmErr = new Error(
756
+ "pushNotifications.markFailed: refusing failed transition from terminal delivered"
757
+ );
758
+ fsmErr.code = "FSM_TERMINAL";
759
+ throw fsmErr;
760
+ }
761
+
762
+ var attempts = Number(existing.attempts || 0) + 1;
763
+ if (retry && attempts < RETRY_BACKOFF_MS.length) {
764
+ // Re-queue with back-off. Pick the entry matching the
765
+ // post-increment attempt index so the first failure waits
766
+ // RETRY_BACKOFF_MS[0] = 1m.
767
+ var nextRetryAt = now + RETRY_BACKOFF_MS[attempts - 1];
768
+ await query(
769
+ "UPDATE push_notifications SET status = 'queued', attempts = ?1, " +
770
+ "next_retry_at = ?2, schedule_at = ?2, fail_reason = ?3 " +
771
+ "WHERE id = ?4",
772
+ [attempts, nextRetryAt, reason, notificationId],
773
+ );
774
+ return await _getNotification(notificationId);
775
+ }
776
+ // Terminal failure — exhausted retry budget OR caller asked
777
+ // for a hard fail.
778
+ await query(
779
+ "UPDATE push_notifications SET status = 'failed', attempts = ?1, " +
780
+ "next_retry_at = NULL, fail_reason = ?2, failed_at = ?3 " +
781
+ "WHERE id = ?4",
782
+ [attempts, reason, now, notificationId],
783
+ );
784
+ return await _getNotification(notificationId);
785
+ },
786
+
787
+ // Scheduler-callable. Pulls every queued notification whose
788
+ // schedule_at is due, flips status to `sent`, and returns the
789
+ // updated rows. The operator's send hook (composed at the route
790
+ // layer) is responsible for the actual provider POST + the
791
+ // subsequent markDelivered / markFailed; this surface only
792
+ // advances the FSM out of `queued`.
793
+ dispatchTick: async function (input) {
794
+ input = input || {};
795
+ var now = input.now == null ? _now() : _epochMs(input.now, "now");
796
+ var batchSize = _batchSize(input.batch_size);
797
+
798
+ var due = await query(
799
+ "SELECT * FROM push_notifications " +
800
+ "WHERE status = 'queued' AND schedule_at <= ?1 " +
801
+ "ORDER BY schedule_at ASC, id ASC LIMIT ?2",
802
+ [now, batchSize],
803
+ );
804
+
805
+ var advanced = [];
806
+ for (var i = 0; i < due.rows.length; i += 1) {
807
+ var row = due.rows[i];
808
+ // The provider_slug stamp at enqueue is the routing
809
+ // decision. A row whose provider is now archived / inactive
810
+ // terminates as failed at tick time — operators see the
811
+ // refusal in the metrics surface rather than chasing a
812
+ // silent queue stall.
813
+ var providerSlug = row.provider_slug;
814
+ var provider = providerSlug ? await _getProvider(providerSlug) : null;
815
+ if (!provider || Number(provider.active) !== 1 || provider.archived_at != null) {
816
+ await query(
817
+ "UPDATE push_notifications SET status = 'failed', fail_reason = ?1, " +
818
+ "failed_at = ?2 WHERE id = ?3",
819
+ ["provider_unavailable_at_dispatch", now, row.id],
820
+ );
821
+ advanced.push(await _getNotification(row.id));
822
+ continue;
823
+ }
824
+ await query(
825
+ "UPDATE push_notifications SET status = 'sent', dispatched_at = ?1 WHERE id = ?2",
826
+ [now, row.id],
827
+ );
828
+ advanced.push(await _getNotification(row.id));
829
+ }
830
+ return advanced;
831
+ },
832
+
833
+ notificationsForCustomer: async function (input) {
834
+ if (!input || typeof input !== "object") {
835
+ throw new TypeError("pushNotifications.notificationsForCustomer: input object required");
836
+ }
837
+ var customerId = _uuid(input.customer_id, "customer_id");
838
+ var limit = _limit(input.limit, "limit");
839
+ var cursor = _optionalEpochMs(input.cursor, "cursor");
840
+
841
+ var sql, params;
842
+ if (cursor == null) {
843
+ sql = "SELECT * FROM push_notifications WHERE recipient_customer_id = ?1 " +
844
+ "ORDER BY created_at DESC, id DESC LIMIT ?2";
845
+ params = [customerId, limit];
846
+ } else {
847
+ sql = "SELECT * FROM push_notifications WHERE recipient_customer_id = ?1 AND created_at < ?2 " +
848
+ "ORDER BY created_at DESC, id DESC LIMIT ?3";
849
+ params = [customerId, cursor, limit];
850
+ }
851
+ var r = await query(sql, params);
852
+ var nextCursor = null;
853
+ if (r.rows.length === limit) {
854
+ nextCursor = Number(r.rows[r.rows.length - 1].created_at);
855
+ }
856
+ return { rows: r.rows, next_cursor: nextCursor };
857
+ },
858
+
859
+ devicesForCustomer: async function (customerId) {
860
+ var cid = _uuid(customerId, "customer_id");
861
+ var r = await query(
862
+ "SELECT * FROM push_devices WHERE customer_id = ?1 ORDER BY revoked_at IS NULL DESC, last_seen_at DESC",
863
+ [cid],
864
+ );
865
+ return r.rows;
866
+ },
867
+
868
+ // Aggregate per-provider engagement over a window. The rate
869
+ // denominators are the count of dispatched notifications
870
+ // (status in {sent, delivered, failed}); `delivery_rate` is
871
+ // delivered/dispatched and `failure_rate` is failed/dispatched.
872
+ // Returns zeros instead of NaN when the window contains no
873
+ // dispatched rows.
874
+ metricsForProvider: async function (input) {
875
+ if (!input || typeof input !== "object") {
876
+ throw new TypeError("pushNotifications.metricsForProvider: input object required");
877
+ }
878
+ var slug = _slug(input.slug, "slug");
879
+ var from = _epochMs(input.from, "from");
880
+ var to = _epochMs(input.to, "to");
881
+ if (to < from) {
882
+ throw new TypeError("pushNotifications.metricsForProvider: 'to' must be >= 'from'");
883
+ }
884
+
885
+ var rows = (await query(
886
+ "SELECT status, COUNT(*) AS n FROM push_notifications " +
887
+ "WHERE provider_slug = ?1 AND created_at >= ?2 AND created_at <= ?3 " +
888
+ "GROUP BY status",
889
+ [slug, from, to],
890
+ )).rows;
891
+
892
+ var counts = {};
893
+ for (var i = 0; i < STATUSES.length; i += 1) counts[STATUSES[i]] = 0;
894
+ for (var j = 0; j < rows.length; j += 1) {
895
+ counts[rows[j].status] = Number(rows[j].n || 0);
896
+ }
897
+ var dispatched = counts.sent + counts.delivered + counts.failed;
898
+ var total = counts.queued + dispatched;
899
+
900
+ function _rate(n, d) { return d > 0 ? n / d : 0; }
901
+
902
+ return {
903
+ slug: slug,
904
+ from: from,
905
+ to: to,
906
+ total: total,
907
+ queued: counts.queued,
908
+ sent: counts.sent,
909
+ delivered: counts.delivered,
910
+ failed: counts.failed,
911
+ dispatched: dispatched,
912
+ delivery_rate: _rate(counts.delivered, dispatched),
913
+ failure_rate: _rate(counts.failed, dispatched),
914
+ };
915
+ },
916
+ };
917
+ }
918
+
919
+ module.exports = {
920
+ create: create,
921
+ KINDS: KINDS.slice(),
922
+ DEVICE_CLASSES: DEVICE_CLASSES.slice(),
923
+ CHANNELS: CHANNELS.slice(),
924
+ STATES: STATES.slice(),
925
+ STATUSES: STATUSES.slice(),
926
+ RETRY_BACKOFF_MS: RETRY_BACKOFF_MS.slice(),
927
+ DEVICE_TOKEN_NAMESPACE: DEVICE_TOKEN_NAMESPACE,
928
+ CREDENTIALS_NAMESPACE: CREDENTIALS_NAMESPACE,
929
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
930
+ MAX_TITLE_LEN: MAX_TITLE_LEN,
931
+ MAX_BODY_LEN: MAX_BODY_LEN,
932
+ MAX_PAYLOAD_BYTES: MAX_PAYLOAD_BYTES,
933
+ MAX_LIST_LIMIT: MAX_LIST_LIMIT,
934
+ DEFAULT_LIST_LIMIT: DEFAULT_LIST_LIMIT,
935
+ MAX_BATCH_SIZE: MAX_BATCH_SIZE,
936
+ DEFAULT_BATCH_SIZE: DEFAULT_BATCH_SIZE,
937
+ };