@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.
- package/CHANGELOG.md +10 -0
- package/lib/assembly-instructions.js +777 -0
- package/lib/auto-replenish.js +933 -0
- package/lib/business-hours.js +980 -0
- package/lib/click-and-collect.js +711 -0
- package/lib/clickstream.js +713 -0
- package/lib/cost-layers.js +774 -0
- package/lib/credit-limits.js +752 -0
- package/lib/currency-rounding.js +525 -0
- package/lib/customer-activity.js +862 -0
- package/lib/customer-notes.js +712 -0
- package/lib/customer-risk-profile.js +593 -0
- package/lib/customer-surveys.js +1012 -0
- package/lib/damage-photos.js +473 -0
- package/lib/discount-allocation.js +557 -0
- package/lib/dropship-forwarding.js +645 -0
- package/lib/email-templates.js +817 -0
- package/lib/index.js +45 -0
- package/lib/inventory-allocations.js +559 -0
- package/lib/inventory-writeoffs.js +636 -0
- package/lib/knowledge-base.js +1104 -0
- package/lib/locale-router.js +1077 -0
- package/lib/operator-roles.js +768 -0
- package/lib/order-escalation.js +951 -0
- package/lib/order-ratings.js +495 -0
- package/lib/order-tags.js +944 -0
- package/lib/packing-slips.js +810 -0
- package/lib/payment-retries.js +816 -0
- package/lib/pick-lists.js +639 -0
- package/lib/pixel-events.js +995 -0
- package/lib/preorder.js +595 -0
- package/lib/print-queue.js +681 -0
- package/lib/product-qa.js +749 -0
- package/lib/promo-bundles.js +835 -0
- package/lib/push-notifications.js +937 -0
- package/lib/refund-automation.js +853 -0
- package/lib/reorder-reminders.js +798 -0
- package/lib/robots-config.js +753 -0
- package/lib/seller-signup.js +1052 -0
- package/lib/site-redirects.js +690 -0
- package/lib/sitemap-generator.js +717 -0
- package/lib/subscription-gifts.js +710 -0
- package/lib/tax-cert-renewals.js +632 -0
- package/lib/theme-assets.js +711 -0
- package/lib/tier-benefits.js +776 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/lib/metrics.js +68 -4
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
- package/lib/wishlist-alerts.js +842 -0
- package/lib/wishlist-sharing.js +718 -0
- 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
|
+
};
|