@blamejs/blamejs-shop 0.0.53 → 0.0.56
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 +6 -0
- package/lib/addresses.js +430 -0
- package/lib/analytics.js +400 -0
- package/lib/cart-abandonment.js +664 -0
- package/lib/currency-display.js +432 -0
- package/lib/email-suppressions.js +579 -0
- package/lib/email.js +264 -0
- package/lib/index.js +14 -0
- package/lib/inventory-receive.js +494 -0
- package/lib/loyalty.js +496 -0
- package/lib/newsletter.js +176 -12
- package/lib/notifications.js +474 -0
- package/lib/order-tracking.js +456 -0
- package/lib/payment.js +193 -13
- package/lib/referrals.js +649 -0
- package/lib/returns.js +627 -0
- package/lib/reviews.js +412 -0
- package/lib/search-suggestions.js +528 -0
- package/lib/tax-exempt.js +519 -0
- package/lib/tax.js +391 -3
- package/lib/vendor/MANIFEST.json +1 -1
- package/lib/webhooks.js +293 -16
- package/lib/wishlist.js +269 -0
- package/package.json +1 -1
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.notifications
|
|
4
|
+
* @title Notifications primitive — queued + scheduled fan-out
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* Write-ahead notifications queue with retry semantics + per-
|
|
8
|
+
* recipient preferences. Callers `enqueue` a row in `pending`
|
|
9
|
+
* state with a `scheduled_at` (defaults to now); an operator-
|
|
10
|
+
* driven scheduler polls `pendingDueAt(ts)` and dispatches each
|
|
11
|
+
* row through the matching channel (in-app, email, webhook),
|
|
12
|
+
* marking it `sent` / `failed` via `markSent` / `markFailed`.
|
|
13
|
+
* Recipient-facing transitions are `markRead` (in-app open) and
|
|
14
|
+
* `dismiss` (in-app hide).
|
|
15
|
+
*
|
|
16
|
+
* Channels:
|
|
17
|
+
* in-app — surfaced in the recipient's inbox via
|
|
18
|
+
* `unreadForRecipient`
|
|
19
|
+
* email — operator dispatcher reads the `payload_json` and
|
|
20
|
+
* hands it to `b.shop.email` (or its own SMTP path)
|
|
21
|
+
* webhook — operator dispatcher hands the row to the
|
|
22
|
+
* `webhooks` primitive
|
|
23
|
+
*
|
|
24
|
+
* Preferences:
|
|
25
|
+
* `setPreference({ recipient_id, event_type, channel, enabled })`
|
|
26
|
+
* writes an opt-in / opt-out row keyed by
|
|
27
|
+
* (recipient_id_hash, event_type, channel). `enqueue` consults
|
|
28
|
+
* this table and refuses with `{ ok: false, error: "opted-out" }`
|
|
29
|
+
* when the target is disabled. Absence of a row is treated as
|
|
30
|
+
* enabled — operators that need opt-in semantics seed the table
|
|
31
|
+
* at registration time.
|
|
32
|
+
*
|
|
33
|
+
* Composes:
|
|
34
|
+
* - b.crypto.namespaceHash — recipient_id derivation
|
|
35
|
+
* (namespace "notification-recipient")
|
|
36
|
+
* - b.uuid.v7 — row id
|
|
37
|
+
* - b.guardUuid — row id sanitization on transition
|
|
38
|
+
* calls
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
var bShop;
|
|
42
|
+
function _b() {
|
|
43
|
+
if (!bShop) bShop = require("./index");
|
|
44
|
+
return bShop.framework;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
var RECIPIENT_NAMESPACE = "notification-recipient";
|
|
48
|
+
|
|
49
|
+
var CHANNELS = ["in-app", "email", "webhook"];
|
|
50
|
+
|
|
51
|
+
var STATUSES = ["pending", "sent", "failed", "dismissed", "read"];
|
|
52
|
+
|
|
53
|
+
var MAX_RECIPIENT_LEN = 256;
|
|
54
|
+
var MAX_EVENT_TYPE_LEN = 128;
|
|
55
|
+
var MAX_TITLE_LEN = 256;
|
|
56
|
+
var MAX_BODY_LEN = 8192;
|
|
57
|
+
var MAX_PAYLOAD_BYTES = 65536;
|
|
58
|
+
var MAX_ERROR_LEN = 1024;
|
|
59
|
+
|
|
60
|
+
var EVENT_TYPE_RE = /^[a-z](?:[a-z0-9._-]*[a-z0-9])?$/;
|
|
61
|
+
var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
|
|
62
|
+
var DEFAULT_PAGE_SIZE = 50;
|
|
63
|
+
var MAX_PAGE_SIZE = 500;
|
|
64
|
+
|
|
65
|
+
// ---- validators ---------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
function _recipientId(s) {
|
|
68
|
+
if (typeof s !== "string" || !s.length) {
|
|
69
|
+
throw new TypeError("notifications: recipient_id must be a non-empty string");
|
|
70
|
+
}
|
|
71
|
+
if (s.length > MAX_RECIPIENT_LEN) {
|
|
72
|
+
throw new TypeError("notifications: recipient_id must be <= " + MAX_RECIPIENT_LEN + " characters");
|
|
73
|
+
}
|
|
74
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
75
|
+
throw new TypeError("notifications: recipient_id contains control bytes");
|
|
76
|
+
}
|
|
77
|
+
return s;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function _channel(s) {
|
|
81
|
+
if (typeof s !== "string" || CHANNELS.indexOf(s) === -1) {
|
|
82
|
+
throw new TypeError("notifications: channel must be one of " + CHANNELS.join(", "));
|
|
83
|
+
}
|
|
84
|
+
return s;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function _eventType(s) {
|
|
88
|
+
if (typeof s !== "string" || !s.length) {
|
|
89
|
+
throw new TypeError("notifications: event_type must be a non-empty string");
|
|
90
|
+
}
|
|
91
|
+
if (s.length > MAX_EVENT_TYPE_LEN) {
|
|
92
|
+
throw new TypeError("notifications: event_type must be <= " + MAX_EVENT_TYPE_LEN + " characters");
|
|
93
|
+
}
|
|
94
|
+
if (!EVENT_TYPE_RE.test(s)) {
|
|
95
|
+
throw new TypeError("notifications: event_type must match /[a-z][a-z0-9._-]*[a-z0-9]/");
|
|
96
|
+
}
|
|
97
|
+
return s;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function _title(s) {
|
|
101
|
+
if (s == null) return "";
|
|
102
|
+
if (typeof s !== "string") {
|
|
103
|
+
throw new TypeError("notifications: title must be a string");
|
|
104
|
+
}
|
|
105
|
+
if (s.length > MAX_TITLE_LEN) {
|
|
106
|
+
throw new TypeError("notifications: title must be <= " + MAX_TITLE_LEN + " characters");
|
|
107
|
+
}
|
|
108
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
109
|
+
throw new TypeError("notifications: title contains control bytes");
|
|
110
|
+
}
|
|
111
|
+
return s;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function _body(s) {
|
|
115
|
+
if (s == null) return "";
|
|
116
|
+
if (typeof s !== "string") {
|
|
117
|
+
throw new TypeError("notifications: body must be a string");
|
|
118
|
+
}
|
|
119
|
+
if (s.length > MAX_BODY_LEN) {
|
|
120
|
+
throw new TypeError("notifications: body must be <= " + MAX_BODY_LEN + " characters");
|
|
121
|
+
}
|
|
122
|
+
return s;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function _payload(p) {
|
|
126
|
+
if (p == null) return "{}";
|
|
127
|
+
if (typeof p !== "object" || Array.isArray(p)) {
|
|
128
|
+
throw new TypeError("notifications: payload must be a plain object");
|
|
129
|
+
}
|
|
130
|
+
var encoded;
|
|
131
|
+
try { encoded = JSON.stringify(p); }
|
|
132
|
+
catch (e) { throw new TypeError("notifications: payload — " + (e && e.message || "not JSON-serialisable")); }
|
|
133
|
+
if (Buffer.byteLength(encoded, "utf8") > MAX_PAYLOAD_BYTES) {
|
|
134
|
+
throw new TypeError("notifications: payload exceeds " + MAX_PAYLOAD_BYTES + " bytes");
|
|
135
|
+
}
|
|
136
|
+
return encoded;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function _scheduledAt(ts, now) {
|
|
140
|
+
if (ts == null) return now;
|
|
141
|
+
if (!Number.isInteger(ts) || ts < 0) {
|
|
142
|
+
throw new TypeError("notifications: scheduled_at must be a non-negative integer (epoch ms)");
|
|
143
|
+
}
|
|
144
|
+
return ts;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function _limit(n, label) {
|
|
148
|
+
if (n == null) return DEFAULT_PAGE_SIZE;
|
|
149
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_PAGE_SIZE) {
|
|
150
|
+
throw new TypeError("notifications: " + label + " must be an integer in 1.." + MAX_PAGE_SIZE);
|
|
151
|
+
}
|
|
152
|
+
return n;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function _uuid(s, label) {
|
|
156
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
157
|
+
catch (e) { throw new TypeError("notifications: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function _error(s) {
|
|
161
|
+
if (typeof s !== "string" || !s.length) {
|
|
162
|
+
throw new TypeError("notifications: error must be a non-empty string");
|
|
163
|
+
}
|
|
164
|
+
if (s.length > MAX_ERROR_LEN) return s.slice(0, MAX_ERROR_LEN);
|
|
165
|
+
return s;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function _now() { return Date.now(); }
|
|
169
|
+
|
|
170
|
+
// ---- factory ------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
function create(opts) {
|
|
173
|
+
opts = opts || {};
|
|
174
|
+
var query = opts.query;
|
|
175
|
+
if (!query) {
|
|
176
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function _hashRecipient(recipientId) {
|
|
180
|
+
return _b().crypto.namespaceHash(RECIPIENT_NAMESPACE, recipientId);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function _isOptedOut(recipientHash, eventType, channel) {
|
|
184
|
+
var r = await query(
|
|
185
|
+
"SELECT enabled FROM notification_preferences " +
|
|
186
|
+
"WHERE recipient_id_hash = ?1 AND event_type = ?2 AND channel = ?3 LIMIT 1",
|
|
187
|
+
[recipientHash, eventType, channel],
|
|
188
|
+
);
|
|
189
|
+
var row = r.rows[0];
|
|
190
|
+
if (!row) return false;
|
|
191
|
+
return Number(row.enabled) === 0;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
RECIPIENT_NAMESPACE: RECIPIENT_NAMESPACE,
|
|
196
|
+
CHANNELS: CHANNELS.slice(),
|
|
197
|
+
STATUSES: STATUSES.slice(),
|
|
198
|
+
|
|
199
|
+
// Hash a recipient id without writing — useful when the caller
|
|
200
|
+
// wants to look the row up under the same key without going
|
|
201
|
+
// through `enqueue`.
|
|
202
|
+
hashRecipient: function (recipientId) {
|
|
203
|
+
return _hashRecipient(_recipientId(recipientId));
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
enqueue: async function (input) {
|
|
207
|
+
if (!input || typeof input !== "object") {
|
|
208
|
+
throw new TypeError("notifications.enqueue: input object required");
|
|
209
|
+
}
|
|
210
|
+
var recipientId = _recipientId(input.recipient_id);
|
|
211
|
+
var channel = _channel(input.channel);
|
|
212
|
+
var eventType = _eventType(input.event_type);
|
|
213
|
+
var title = _title(input.title);
|
|
214
|
+
var body = _body(input.body);
|
|
215
|
+
var payload = _payload(input.payload);
|
|
216
|
+
var now = _now();
|
|
217
|
+
var scheduledAt = _scheduledAt(input.scheduled_at, now);
|
|
218
|
+
var hash = _hashRecipient(recipientId);
|
|
219
|
+
|
|
220
|
+
// Preference check before writing — operator opt-outs short-
|
|
221
|
+
// circuit the queue rather than write a row only to drop it
|
|
222
|
+
// at dispatch time.
|
|
223
|
+
if (await _isOptedOut(hash, eventType, channel)) {
|
|
224
|
+
return { ok: false, error: "opted-out" };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
var id = _b().uuid.v7();
|
|
228
|
+
await query(
|
|
229
|
+
"INSERT INTO notifications " +
|
|
230
|
+
"(id, recipient_id, recipient_id_hash, channel, event_type, title, body, " +
|
|
231
|
+
" payload_json, status, scheduled_at, retry_count, created_at, updated_at) " +
|
|
232
|
+
"VALUES (?1, ?2, ?2, ?3, ?4, ?5, ?6, ?7, 'pending', ?8, 0, ?9, ?9)",
|
|
233
|
+
[id, hash, channel, eventType, title, body, payload, scheduledAt, now],
|
|
234
|
+
);
|
|
235
|
+
return { ok: true, id: id, scheduled_at: scheduledAt };
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
get: async function (id) {
|
|
239
|
+
_uuid(id, "notification id");
|
|
240
|
+
var r = await query("SELECT * FROM notifications WHERE id = ?1", [id]);
|
|
241
|
+
return r.rows[0] || null;
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
markSent: async function (id, options) {
|
|
245
|
+
_uuid(id, "notification id");
|
|
246
|
+
options = options || {};
|
|
247
|
+
var sentAt = options.sent_at == null ? _now() : _scheduledAt(options.sent_at, _now());
|
|
248
|
+
var existing = (await query("SELECT status FROM notifications WHERE id = ?1", [id])).rows[0];
|
|
249
|
+
if (!existing) return null;
|
|
250
|
+
if (existing.status !== "pending" && existing.status !== "failed") {
|
|
251
|
+
var err = new Error("notifications.markSent: cannot transition from " + existing.status + " to sent");
|
|
252
|
+
err.code = "NOTIFICATION_BAD_TRANSITION";
|
|
253
|
+
throw err;
|
|
254
|
+
}
|
|
255
|
+
await query(
|
|
256
|
+
"UPDATE notifications SET status = 'sent', sent_at = ?1, last_error = NULL, updated_at = ?1 WHERE id = ?2",
|
|
257
|
+
[sentAt, id],
|
|
258
|
+
);
|
|
259
|
+
return (await query("SELECT * FROM notifications WHERE id = ?1", [id])).rows[0];
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
markFailed: async function (id, options) {
|
|
263
|
+
_uuid(id, "notification id");
|
|
264
|
+
if (!options || typeof options !== "object") {
|
|
265
|
+
throw new TypeError("notifications.markFailed: options.error required");
|
|
266
|
+
}
|
|
267
|
+
var errMsg = _error(options.error);
|
|
268
|
+
var increment = options.retry_count_increment;
|
|
269
|
+
if (increment == null) increment = 1;
|
|
270
|
+
if (!Number.isInteger(increment) || increment < 0) {
|
|
271
|
+
throw new TypeError("notifications.markFailed: retry_count_increment must be a non-negative integer");
|
|
272
|
+
}
|
|
273
|
+
var existing = (await query("SELECT status, retry_count FROM notifications WHERE id = ?1", [id])).rows[0];
|
|
274
|
+
if (!existing) return null;
|
|
275
|
+
if (existing.status !== "pending" && existing.status !== "failed") {
|
|
276
|
+
var err = new Error("notifications.markFailed: cannot transition from " + existing.status + " to failed");
|
|
277
|
+
err.code = "NOTIFICATION_BAD_TRANSITION";
|
|
278
|
+
throw err;
|
|
279
|
+
}
|
|
280
|
+
var newCount = Number(existing.retry_count) + increment;
|
|
281
|
+
var ts = _now();
|
|
282
|
+
await query(
|
|
283
|
+
"UPDATE notifications SET status = 'failed', retry_count = ?1, last_error = ?2, updated_at = ?3 WHERE id = ?4",
|
|
284
|
+
[newCount, errMsg, ts, id],
|
|
285
|
+
);
|
|
286
|
+
return (await query("SELECT * FROM notifications WHERE id = ?1", [id])).rows[0];
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
markRead: async function (id, options) {
|
|
290
|
+
_uuid(id, "notification id");
|
|
291
|
+
options = options || {};
|
|
292
|
+
var readAt = options.read_at == null ? _now() : _scheduledAt(options.read_at, _now());
|
|
293
|
+
var existing = (await query("SELECT status, channel FROM notifications WHERE id = ?1", [id])).rows[0];
|
|
294
|
+
if (!existing) return null;
|
|
295
|
+
if (existing.channel !== "in-app") {
|
|
296
|
+
var err = new Error("notifications.markRead: only in-app notifications can be marked read");
|
|
297
|
+
err.code = "NOTIFICATION_BAD_CHANNEL";
|
|
298
|
+
throw err;
|
|
299
|
+
}
|
|
300
|
+
if (existing.status !== "sent" && existing.status !== "pending") {
|
|
301
|
+
var err2 = new Error("notifications.markRead: cannot transition from " + existing.status + " to read");
|
|
302
|
+
err2.code = "NOTIFICATION_BAD_TRANSITION";
|
|
303
|
+
throw err2;
|
|
304
|
+
}
|
|
305
|
+
await query(
|
|
306
|
+
"UPDATE notifications SET status = 'read', read_at = ?1, updated_at = ?1 WHERE id = ?2",
|
|
307
|
+
[readAt, id],
|
|
308
|
+
);
|
|
309
|
+
return (await query("SELECT * FROM notifications WHERE id = ?1", [id])).rows[0];
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
dismiss: async function (id) {
|
|
313
|
+
_uuid(id, "notification id");
|
|
314
|
+
var existing = (await query("SELECT status FROM notifications WHERE id = ?1", [id])).rows[0];
|
|
315
|
+
if (!existing) return null;
|
|
316
|
+
if (existing.status === "dismissed") {
|
|
317
|
+
return (await query("SELECT * FROM notifications WHERE id = ?1", [id])).rows[0];
|
|
318
|
+
}
|
|
319
|
+
var ts = _now();
|
|
320
|
+
await query(
|
|
321
|
+
"UPDATE notifications SET status = 'dismissed', dismissed_at = ?1, updated_at = ?1 WHERE id = ?2",
|
|
322
|
+
[ts, id],
|
|
323
|
+
);
|
|
324
|
+
return (await query("SELECT * FROM notifications WHERE id = ?1", [id])).rows[0];
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
unreadForRecipient: async function (recipientId, options) {
|
|
328
|
+
var rid = _recipientId(recipientId);
|
|
329
|
+
options = options || {};
|
|
330
|
+
var limit = _limit(options.limit, "limit");
|
|
331
|
+
var hash = _hashRecipient(rid);
|
|
332
|
+
var params;
|
|
333
|
+
var sql;
|
|
334
|
+
if (options.channel != null) {
|
|
335
|
+
var channel = _channel(options.channel);
|
|
336
|
+
sql = "SELECT * FROM notifications " +
|
|
337
|
+
"WHERE recipient_id_hash = ?1 AND channel = ?2 " +
|
|
338
|
+
"AND status IN ('pending','sent','failed') " +
|
|
339
|
+
"ORDER BY scheduled_at DESC, id DESC LIMIT ?3";
|
|
340
|
+
params = [hash, channel, limit];
|
|
341
|
+
} else {
|
|
342
|
+
sql = "SELECT * FROM notifications " +
|
|
343
|
+
"WHERE recipient_id_hash = ?1 " +
|
|
344
|
+
"AND status IN ('pending','sent','failed') " +
|
|
345
|
+
"ORDER BY scheduled_at DESC, id DESC LIMIT ?2";
|
|
346
|
+
params = [hash, limit];
|
|
347
|
+
}
|
|
348
|
+
var r = await query(sql, params);
|
|
349
|
+
return r.rows;
|
|
350
|
+
},
|
|
351
|
+
|
|
352
|
+
pendingDueAt: async function (ts, options) {
|
|
353
|
+
if (!Number.isInteger(ts) || ts < 0) {
|
|
354
|
+
throw new TypeError("notifications.pendingDueAt: ts must be a non-negative integer (epoch ms)");
|
|
355
|
+
}
|
|
356
|
+
options = options || {};
|
|
357
|
+
var limit = _limit(options.limit, "limit");
|
|
358
|
+
var r = await query(
|
|
359
|
+
"SELECT * FROM notifications " +
|
|
360
|
+
"WHERE status = 'pending' AND scheduled_at <= ?1 " +
|
|
361
|
+
"ORDER BY scheduled_at ASC, id ASC LIMIT ?2",
|
|
362
|
+
[ts, limit],
|
|
363
|
+
);
|
|
364
|
+
return r.rows;
|
|
365
|
+
},
|
|
366
|
+
|
|
367
|
+
setPreference: async function (input) {
|
|
368
|
+
if (!input || typeof input !== "object") {
|
|
369
|
+
throw new TypeError("notifications.setPreference: input object required");
|
|
370
|
+
}
|
|
371
|
+
var recipientId = _recipientId(input.recipient_id);
|
|
372
|
+
var eventType = _eventType(input.event_type);
|
|
373
|
+
var channel = _channel(input.channel);
|
|
374
|
+
if (typeof input.enabled !== "boolean") {
|
|
375
|
+
throw new TypeError("notifications.setPreference: enabled must be a boolean");
|
|
376
|
+
}
|
|
377
|
+
var enabled = input.enabled ? 1 : 0;
|
|
378
|
+
var hash = _hashRecipient(recipientId);
|
|
379
|
+
var now = _now();
|
|
380
|
+
// Upsert — composite primary key handles dedupe. SQLite's
|
|
381
|
+
// ON CONFLICT REPLACE updates updated_at without dropping
|
|
382
|
+
// created_at on the existing row.
|
|
383
|
+
var existing = (await query(
|
|
384
|
+
"SELECT created_at FROM notification_preferences " +
|
|
385
|
+
"WHERE recipient_id_hash = ?1 AND event_type = ?2 AND channel = ?3 LIMIT 1",
|
|
386
|
+
[hash, eventType, channel],
|
|
387
|
+
)).rows[0];
|
|
388
|
+
if (existing) {
|
|
389
|
+
await query(
|
|
390
|
+
"UPDATE notification_preferences SET enabled = ?1, updated_at = ?2 " +
|
|
391
|
+
"WHERE recipient_id_hash = ?3 AND event_type = ?4 AND channel = ?5",
|
|
392
|
+
[enabled, now, hash, eventType, channel],
|
|
393
|
+
);
|
|
394
|
+
return {
|
|
395
|
+
recipient_id_hash: hash,
|
|
396
|
+
event_type: eventType,
|
|
397
|
+
channel: channel,
|
|
398
|
+
enabled: enabled,
|
|
399
|
+
created_at: Number(existing.created_at),
|
|
400
|
+
updated_at: now,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
await query(
|
|
404
|
+
"INSERT INTO notification_preferences " +
|
|
405
|
+
"(recipient_id_hash, event_type, channel, enabled, created_at, updated_at) " +
|
|
406
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?5)",
|
|
407
|
+
[hash, eventType, channel, enabled, now],
|
|
408
|
+
);
|
|
409
|
+
return {
|
|
410
|
+
recipient_id_hash: hash,
|
|
411
|
+
event_type: eventType,
|
|
412
|
+
channel: channel,
|
|
413
|
+
enabled: enabled,
|
|
414
|
+
created_at: now,
|
|
415
|
+
updated_at: now,
|
|
416
|
+
};
|
|
417
|
+
},
|
|
418
|
+
|
|
419
|
+
getPreferences: async function (recipientId) {
|
|
420
|
+
var rid = _recipientId(recipientId);
|
|
421
|
+
var hash = _hashRecipient(rid);
|
|
422
|
+
var r = await query(
|
|
423
|
+
"SELECT recipient_id_hash, event_type, channel, enabled, created_at, updated_at " +
|
|
424
|
+
"FROM notification_preferences WHERE recipient_id_hash = ?1 " +
|
|
425
|
+
"ORDER BY event_type ASC, channel ASC",
|
|
426
|
+
[hash],
|
|
427
|
+
);
|
|
428
|
+
return r.rows;
|
|
429
|
+
},
|
|
430
|
+
|
|
431
|
+
// Operator retention cleanup. `before_ts` is an epoch-ms cutoff;
|
|
432
|
+
// rows with `updated_at <= before_ts` AND `status IN statuses`
|
|
433
|
+
// are deleted. Refuses an empty statuses array (operators must
|
|
434
|
+
// be explicit about which terminal states they are reclaiming —
|
|
435
|
+
// accidentally deleting `pending` rows would silently drop the
|
|
436
|
+
// queue).
|
|
437
|
+
cleanupOld: async function (options) {
|
|
438
|
+
if (!options || typeof options !== "object") {
|
|
439
|
+
throw new TypeError("notifications.cleanupOld: options object required");
|
|
440
|
+
}
|
|
441
|
+
if (!Number.isInteger(options.before_ts) || options.before_ts < 0) {
|
|
442
|
+
throw new TypeError("notifications.cleanupOld: before_ts must be a non-negative integer (epoch ms)");
|
|
443
|
+
}
|
|
444
|
+
if (!Array.isArray(options.statuses) || options.statuses.length === 0) {
|
|
445
|
+
throw new TypeError("notifications.cleanupOld: statuses must be a non-empty array");
|
|
446
|
+
}
|
|
447
|
+
var seen = {};
|
|
448
|
+
for (var i = 0; i < options.statuses.length; i += 1) {
|
|
449
|
+
var s = options.statuses[i];
|
|
450
|
+
if (STATUSES.indexOf(s) === -1) {
|
|
451
|
+
throw new TypeError("notifications.cleanupOld: unknown status " + JSON.stringify(s));
|
|
452
|
+
}
|
|
453
|
+
seen[s] = true;
|
|
454
|
+
}
|
|
455
|
+
// Hand-rolled IN clause — every status token is already
|
|
456
|
+
// validated against the closed list above, so direct
|
|
457
|
+
// interpolation is safe; parameters go in via positional
|
|
458
|
+
// placeholders for the timestamp.
|
|
459
|
+
var statusList = Object.keys(seen).map(function (k) { return "'" + k + "'"; }).join(",");
|
|
460
|
+
var r = await query(
|
|
461
|
+
"DELETE FROM notifications WHERE updated_at <= ?1 AND status IN (" + statusList + ")",
|
|
462
|
+
[options.before_ts],
|
|
463
|
+
);
|
|
464
|
+
return { deleted: Number(r.rowCount || 0) };
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
module.exports = {
|
|
470
|
+
create: create,
|
|
471
|
+
RECIPIENT_NAMESPACE: RECIPIENT_NAMESPACE,
|
|
472
|
+
CHANNELS: CHANNELS.slice(),
|
|
473
|
+
STATUSES: STATUSES.slice(),
|
|
474
|
+
};
|