@blamejs/blamejs-shop 0.0.72 → 0.0.76

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 (44) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/lib/announcement-bar.js +753 -0
  3. package/lib/banner-ab-tests.js +806 -0
  4. package/lib/bin-locations.js +791 -0
  5. package/lib/blog-articles.js +1173 -0
  6. package/lib/carrier-accounts.js +805 -0
  7. package/lib/cart-recovery.js +1133 -0
  8. package/lib/category-navigation.js +934 -0
  9. package/lib/consent-ledger.js +539 -0
  10. package/lib/customer-impersonation.js +743 -0
  11. package/lib/customer-merge.js +879 -0
  12. package/lib/demand-forecast.js +1121 -0
  13. package/lib/dispute-resolution.js +886 -0
  14. package/lib/email-ab-tests.js +918 -0
  15. package/lib/email-engagement-score.js +649 -0
  16. package/lib/event-log.js +713 -0
  17. package/lib/fulfillment-sla.js +791 -0
  18. package/lib/index.js +41 -0
  19. package/lib/inventory-audits.js +852 -0
  20. package/lib/line-gift-wrap.js +430 -0
  21. package/lib/marketing-budget.js +792 -0
  22. package/lib/operator-activity-feed.js +977 -0
  23. package/lib/operator-approvals.js +942 -0
  24. package/lib/operator-help-center.js +1020 -0
  25. package/lib/operator-inbox.js +889 -0
  26. package/lib/operator-sessions.js +701 -0
  27. package/lib/order-exchanges.js +602 -0
  28. package/lib/product-compare.js +804 -0
  29. package/lib/pwa-manifest.js +1005 -0
  30. package/lib/referral-leaderboard.js +612 -0
  31. package/lib/sales-tax-filings.js +807 -0
  32. package/lib/search-ranking.js +859 -0
  33. package/lib/shipping-insurance.js +757 -0
  34. package/lib/shrinkage-report.js +1182 -0
  35. package/lib/sidebar-widgets.js +952 -0
  36. package/lib/smart-restocking.js +1048 -0
  37. package/lib/stock-receipts.js +834 -0
  38. package/lib/subscription-analytics.js +1032 -0
  39. package/lib/suggestion-box.js +921 -0
  40. package/lib/tax-remittance.js +625 -0
  41. package/lib/vendor-invoices.js +1021 -0
  42. package/lib/winback-campaigns.js +1350 -0
  43. package/lib/wishlist-digest.js +1133 -0
  44. package/package.json +1 -1
@@ -0,0 +1,649 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.emailEngagementScore
4
+ * @title Email engagement score — per-customer 0..100 score derived
5
+ * from open / click / unsubscribe / spam-complaint events.
6
+ *
7
+ * @intro
8
+ * Marketing-list health is a long-running aggregation problem: a
9
+ * customer who opens every weekly newsletter and clicks one link a
10
+ * month is worth a richer send cadence; a customer who hasn't opened
11
+ * a message in three months but never formally unsubscribed is a
12
+ * future spam complaint waiting to happen. Sending to lapsed
13
+ * addresses degrades the sender's domain reputation across the
14
+ * entire list, so the primitive's purpose is to give the operator a
15
+ * single integer per customer (and a band label) so the campaign
16
+ * scheduler can filter the audience before a send rather than after
17
+ * the deliverability damage is done.
18
+ *
19
+ * Distinct from `emailSuppressions` (migration 0028), which is the
20
+ * absolute opt-out / bounce / complaint gate: a row there blocks
21
+ * every matching send. The engagement score is the softer signal —
22
+ * marketing surfaces (`emailCampaigns`, dunning reminders,
23
+ * abandoned-cart) read this primitive at audience-resolution time
24
+ * and drop recipients below the operator's chosen band so the
25
+ * sending domain doesn't decay from broadcasting into silent
26
+ * inboxes.
27
+ *
28
+ * Six observable event types, each with a calibrated default
29
+ * weight:
30
+ *
31
+ * opened +5
32
+ * clicked +15
33
+ * unsubscribed -50 (immediately drops the band)
34
+ * spam_reported -75 (worst possible — destroys reputation)
35
+ * bounced -10
36
+ * not_opened_in_window -3 (silent decay — cron-driven)
37
+ *
38
+ * The running score starts at 50 (the operator's first impression
39
+ * of a fresh customer is "neutral, prove yourself either way"), is
40
+ * accumulated from the event log on every recompute, and is clamped
41
+ * to the closed interval 0..100.
42
+ *
43
+ * Bands (closed intervals on score):
44
+ *
45
+ * 75 .. 100 → highly_engaged
46
+ * 50 .. 74 → engaged
47
+ * 20 .. 49 → lapsed
48
+ * 0 .. 19 → unengaged
49
+ *
50
+ * The thresholds are exposed on `emailEngagementScore.BANDS` so the
51
+ * integrator's dashboard and the test suite assert against the same
52
+ * numbers the runtime uses.
53
+ *
54
+ * Surface:
55
+ * - recordEngagementEvent({ customer_id, event_type, occurred_at? })
56
+ * - getScore(customer_id)
57
+ * - recompute(customer_id)
58
+ * - recomputeAll({ since })
59
+ * - unengagedCustomers({ band_max, limit })
60
+ * - metricsForBand({ band, from?, to? })
61
+ * - historyForCustomer(customer_id, { from?, to?, limit? })
62
+ *
63
+ * Composition surface:
64
+ *
65
+ * var eng = bShop.emailEngagementScore.create({ query: q });
66
+ * await eng.recordEngagementEvent({
67
+ * customer_id: cid, event_type: "opened",
68
+ * });
69
+ * var view = await eng.getScore(cid);
70
+ * // { customer_id, score, band, last_opened_at, last_clicked_at,
71
+ * // send_count, open_count, click_count, open_rate, click_rate,
72
+ * // computed_at }
73
+ *
74
+ * Storage:
75
+ * - email_engagement_events (migration 0187)
76
+ * - email_engagement_scores (migration 0187)
77
+ *
78
+ * Optional handles (all injectable on create()):
79
+ * - query — D1-shaped async query function (required)
80
+ * - emailCampaigns — caller correlator for "this engagement
81
+ * event came from a campaign send"; stored
82
+ * on the instance for an integrator's
83
+ * cross-primitive orchestration, never
84
+ * reached into by this primitive
85
+ * - emailSuppressions — caller correlator for the suppression-on-
86
+ * spam_reported reflex; same posture as the
87
+ * campaign handle (stored, not consumed)
88
+ *
89
+ * Recording an event is always an explicit caller action — never an
90
+ * implicit cross-primitive side effect. That keeps the score audit
91
+ * trail clean (every change in the score has exactly one recorded
92
+ * event behind it) and lets the test suite isolate each event
93
+ * source.
94
+ *
95
+ * Monotonic per-customer occurred_at: two writes against the same
96
+ * customer in the same millisecond would tie on `occurred_at` and
97
+ * make the "latest event" read ambiguous in the `(customer_id,
98
+ * occurred_at DESC)` index. `_resolveOccurredAt` bumps the
99
+ * requested timestamp to `prior + 1` on collision, guaranteeing
100
+ * strict monotonicity (same discipline as customer_risk_signals).
101
+ *
102
+ * Recording an event refreshes the denormalized summary in lockstep
103
+ * so getScore / unengagedCustomers / metricsForBand all answer from
104
+ * a single-row read on the hot path.
105
+ *
106
+ * @primitive emailEngagementScore
107
+ * @related b.uuid.v7, b.guardUuid, shop.emailCampaigns, shop.emailSuppressions
108
+ */
109
+
110
+ var bShop;
111
+ function _b() {
112
+ if (!bShop) {
113
+ try {
114
+ bShop = require("./index");
115
+ } catch (_e) {
116
+ // Fallback path — the lib/index.js registry mutation is the
117
+ // operator's responsibility. The framework surface this
118
+ // primitive consumes (guardUuid, uuid) is stable; no shop-level
119
+ // peer is touched through _b(), so the bypass is semantically
120
+ // equivalent until the registry edit lands.
121
+ bShop = { framework: require("./vendor/blamejs") };
122
+ }
123
+ }
124
+ return bShop.framework;
125
+ }
126
+
127
+ // ---- constants ----------------------------------------------------------
128
+
129
+ var EVENT_TYPES = Object.freeze([
130
+ "opened",
131
+ "clicked",
132
+ "unsubscribed",
133
+ "spam_reported",
134
+ "bounced",
135
+ "not_opened_in_window",
136
+ ]);
137
+
138
+ // Per-event score deltas. The operator who needs a different
139
+ // calibration forks the constant on a project-local module and
140
+ // composes the primitive against that; the exposed table is the
141
+ // runtime's single source of truth.
142
+ var WEIGHTS = Object.freeze({
143
+ opened: 5,
144
+ clicked: 15,
145
+ unsubscribed: -50,
146
+ spam_reported: -75,
147
+ bounced: -10,
148
+ not_opened_in_window: -3,
149
+ });
150
+
151
+ // Starting score for a customer with no events on file. Neutral —
152
+ // neither the campaign scheduler nor the suppression gate has a
153
+ // reason to treat the address as risky until evidence accumulates.
154
+ var STARTING_SCORE = 50;
155
+
156
+ // Closed intervals on score. Score 0..19 → unengaged, 20..49 →
157
+ // lapsed, 50..74 → engaged, 75..100 → highly_engaged. Exposed so
158
+ // the test suite + the dashboard renderer pull the same numbers the
159
+ // runtime uses.
160
+ var BANDS = Object.freeze({
161
+ UNENGAGED_MAX: 19,
162
+ LAPSED_MAX: 49,
163
+ ENGAGED_MAX: 74,
164
+ });
165
+
166
+ var BAND_NAMES = Object.freeze([
167
+ "unengaged", "lapsed", "engaged", "highly_engaged",
168
+ ]);
169
+
170
+ // Events that count toward `send_count` (the denominator for
171
+ // open_rate / click_rate). A bounce IS a send (the message was
172
+ // dispatched but rejected by the destination); an unsubscribe /
173
+ // spam_reported is NOT a fresh send signal — it's a response to a
174
+ // prior delivery, captured separately.
175
+ var SEND_EVENTS = Object.freeze({
176
+ opened: true,
177
+ clicked: true,
178
+ bounced: true,
179
+ not_opened_in_window: true,
180
+ });
181
+
182
+ // List-read upper bound — keeps unengagedCustomers / metricsForBand
183
+ // from accidentally returning a giant page when the operator forgets
184
+ // to bound their query.
185
+ var MAX_LIST_LIMIT = 500;
186
+
187
+ // ---- monotonic clock ---------------------------------------------------
188
+ //
189
+ // Two writes against the same customer in the same millisecond would
190
+ // tie on occurred_at and corrupt the (customer_id, occurred_at DESC)
191
+ // index ordering. Bumping by 1ms on a tie keeps the timeline strictly
192
+ // increasing so a sort-by-timestamp read returns events in the order
193
+ // they were issued.
194
+
195
+ var _lastTs = 0;
196
+ function _now() {
197
+ var t = Date.now();
198
+ if (t <= _lastTs) { t = _lastTs + 1; }
199
+ _lastTs = t;
200
+ return t;
201
+ }
202
+
203
+ // ---- validators --------------------------------------------------------
204
+
205
+ function _uuid(s, label) {
206
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
207
+ catch (e) {
208
+ throw new TypeError("emailEngagementScore: " + label +
209
+ " — " + (e && e.message || "invalid UUID"));
210
+ }
211
+ }
212
+
213
+ function _eventType(s) {
214
+ if (typeof s !== "string" || EVENT_TYPES.indexOf(s) === -1) {
215
+ throw new TypeError("emailEngagementScore: event_type must be one of " +
216
+ EVENT_TYPES.join(", ") + ", got " + JSON.stringify(s));
217
+ }
218
+ return s;
219
+ }
220
+
221
+ function _epochMs(ts, label) {
222
+ if (ts == null) return null;
223
+ if (typeof ts !== "number" || !Number.isInteger(ts) || ts < 0) {
224
+ throw new TypeError("emailEngagementScore: " + label +
225
+ " must be a non-negative integer epoch-ms");
226
+ }
227
+ return ts;
228
+ }
229
+
230
+ function _band(s) {
231
+ if (typeof s !== "string" || BAND_NAMES.indexOf(s) === -1) {
232
+ throw new TypeError("emailEngagementScore: band must be one of " +
233
+ BAND_NAMES.join(", "));
234
+ }
235
+ return s;
236
+ }
237
+
238
+ function _positiveInt(n, label) {
239
+ if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
240
+ throw new TypeError("emailEngagementScore: " + label +
241
+ " must be a positive integer");
242
+ }
243
+ return n;
244
+ }
245
+
246
+ function _limit(n) {
247
+ _positiveInt(n, "limit");
248
+ if (n > MAX_LIST_LIMIT) {
249
+ throw new TypeError("emailEngagementScore: limit must be <= " +
250
+ MAX_LIST_LIMIT);
251
+ }
252
+ return n;
253
+ }
254
+
255
+ function _clampScore(n) {
256
+ if (n < 0) return 0;
257
+ if (n > 100) return 100;
258
+ return n;
259
+ }
260
+
261
+ function _bandFor(score) {
262
+ if (score <= BANDS.UNENGAGED_MAX) return "unengaged";
263
+ if (score <= BANDS.LAPSED_MAX) return "lapsed";
264
+ if (score <= BANDS.ENGAGED_MAX) return "engaged";
265
+ return "highly_engaged";
266
+ }
267
+
268
+ function _rate(num, den) {
269
+ if (!den) return 0;
270
+ // Four-decimal-place rate so the dashboard renders 0.1234 without
271
+ // floating-point trailing noise. The input is integer / integer so
272
+ // Math.round is exact.
273
+ return Math.round((num / den) * 10000) / 10000;
274
+ }
275
+
276
+ // ---- factory -----------------------------------------------------------
277
+
278
+ function create(opts) {
279
+ opts = opts || {};
280
+ var query = opts.query;
281
+ if (!query) {
282
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
283
+ }
284
+
285
+ // Optional caller-correlator handles. Stored so an integrator can
286
+ // recover them off the instance for orchestration; not consumed by
287
+ // this primitive directly. Recording an event is always an explicit
288
+ // operator call, never an implicit cross-primitive side effect.
289
+ var emailCampaigns = opts.emailCampaigns || null;
290
+ var emailSuppressions = opts.emailSuppressions || null;
291
+
292
+ async function _readLatestEventTs(customerId) {
293
+ var r = await query(
294
+ "SELECT occurred_at FROM email_engagement_events " +
295
+ "WHERE customer_id = ?1 ORDER BY occurred_at DESC LIMIT 1",
296
+ [customerId],
297
+ );
298
+ return r.rows.length ? r.rows[0].occurred_at : null;
299
+ }
300
+
301
+ // Same monotonic-clock discipline as customer_risk_signals: two
302
+ // writes against the same customer in the same millisecond would
303
+ // tie on occurred_at and corrupt the (customer_id, occurred_at
304
+ // DESC) index ordering. Bump the second write to prior + 1.
305
+ function _resolveOccurredAt(requestedTs, latestTs) {
306
+ if (latestTs == null) return requestedTs;
307
+ if (requestedTs > latestTs) return requestedTs;
308
+ return latestTs + 1;
309
+ }
310
+
311
+ async function _readSummary(customerId) {
312
+ var r = await query(
313
+ "SELECT customer_id, score, band, last_opened_at, last_clicked_at, " +
314
+ "send_count, open_count, click_count, computed_at " +
315
+ "FROM email_engagement_scores WHERE customer_id = ?1 LIMIT 1",
316
+ [customerId],
317
+ );
318
+ return r.rows.length ? r.rows[0] : null;
319
+ }
320
+
321
+ // Recompute the denormalized summary row for one customer from the
322
+ // event log. Walks every event in chronological order, applies the
323
+ // weight table, clamps to 0..100, and upserts the summary. Returns
324
+ // the fresh summary shape so callers don't re-read.
325
+ async function _recomputeOne(customerId, now) {
326
+ var r = await query(
327
+ "SELECT event_type, occurred_at FROM email_engagement_events " +
328
+ "WHERE customer_id = ?1 ORDER BY occurred_at ASC",
329
+ [customerId],
330
+ );
331
+
332
+ var score = STARTING_SCORE;
333
+ var lastOpenedAt = null;
334
+ var lastClickedAt = null;
335
+ var sendCount = 0;
336
+ var openCount = 0;
337
+ var clickCount = 0;
338
+
339
+ for (var i = 0; i < r.rows.length; i += 1) {
340
+ var row = r.rows[i];
341
+ var et = row.event_type;
342
+ score += (Object.prototype.hasOwnProperty.call(WEIGHTS, et) ? WEIGHTS[et] : 0);
343
+ if (SEND_EVENTS[et]) sendCount += 1;
344
+ if (et === "opened") {
345
+ openCount += 1;
346
+ if (lastOpenedAt == null || row.occurred_at > lastOpenedAt) {
347
+ lastOpenedAt = row.occurred_at;
348
+ }
349
+ } else if (et === "clicked") {
350
+ clickCount += 1;
351
+ if (lastClickedAt == null || row.occurred_at > lastClickedAt) {
352
+ lastClickedAt = row.occurred_at;
353
+ }
354
+ // A click implies an open even if the open beacon never
355
+ // fired (image-blocking clients). Bump open_count + stamp
356
+ // last_opened_at so the rate columns stay honest.
357
+ openCount += 1;
358
+ if (lastOpenedAt == null || row.occurred_at > lastOpenedAt) {
359
+ lastOpenedAt = row.occurred_at;
360
+ }
361
+ }
362
+ }
363
+
364
+ score = _clampScore(score);
365
+ var band = _bandFor(score);
366
+
367
+ var existing = await _readSummary(customerId);
368
+ if (existing) {
369
+ await query(
370
+ "UPDATE email_engagement_scores SET score = ?1, band = ?2, " +
371
+ "last_opened_at = ?3, last_clicked_at = ?4, send_count = ?5, " +
372
+ "open_count = ?6, click_count = ?7, computed_at = ?8 " +
373
+ "WHERE customer_id = ?9",
374
+ [score, band, lastOpenedAt, lastClickedAt, sendCount,
375
+ openCount, clickCount, now, customerId],
376
+ );
377
+ } else {
378
+ await query(
379
+ "INSERT INTO email_engagement_scores " +
380
+ "(customer_id, score, band, last_opened_at, last_clicked_at, " +
381
+ "send_count, open_count, click_count, computed_at) " +
382
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
383
+ [customerId, score, band, lastOpenedAt, lastClickedAt,
384
+ sendCount, openCount, clickCount, now],
385
+ );
386
+ }
387
+
388
+ return {
389
+ customer_id: customerId,
390
+ score: score,
391
+ band: band,
392
+ last_opened_at: lastOpenedAt,
393
+ last_clicked_at: lastClickedAt,
394
+ send_count: sendCount,
395
+ open_count: openCount,
396
+ click_count: clickCount,
397
+ computed_at: now,
398
+ };
399
+ }
400
+
401
+ function _hydrate(summary) {
402
+ var sendCount = Number(summary.send_count);
403
+ var openCount = Number(summary.open_count);
404
+ var clickCount = Number(summary.click_count);
405
+ return {
406
+ customer_id: summary.customer_id,
407
+ score: Number(summary.score),
408
+ band: summary.band,
409
+ last_opened_at: summary.last_opened_at,
410
+ last_clicked_at: summary.last_clicked_at,
411
+ send_count: sendCount,
412
+ open_count: openCount,
413
+ click_count: clickCount,
414
+ open_rate: _rate(openCount, sendCount),
415
+ click_rate: _rate(clickCount, sendCount),
416
+ computed_at: summary.computed_at,
417
+ };
418
+ }
419
+
420
+ return {
421
+
422
+ EVENT_TYPES: EVENT_TYPES,
423
+ BAND_NAMES: BAND_NAMES,
424
+ BANDS: { UNENGAGED_MAX: BANDS.UNENGAGED_MAX,
425
+ LAPSED_MAX: BANDS.LAPSED_MAX,
426
+ ENGAGED_MAX: BANDS.ENGAGED_MAX },
427
+ WEIGHTS: WEIGHTS,
428
+ STARTING_SCORE: STARTING_SCORE,
429
+
430
+ // Stored optional handles — exposed read-only so an integrator
431
+ // can recover them off the instance for cross-primitive
432
+ // orchestration. The primitive itself never reaches in.
433
+ handles: {
434
+ emailCampaigns: emailCampaigns,
435
+ emailSuppressions: emailSuppressions,
436
+ },
437
+
438
+ recordEngagementEvent: async function (input) {
439
+ if (!input || typeof input !== "object") {
440
+ throw new TypeError("emailEngagementScore.recordEngagementEvent: input object required");
441
+ }
442
+ var customerId = _uuid(input.customer_id, "customer_id");
443
+ var eventType = _eventType(input.event_type);
444
+ var requested = _epochMs(input.occurred_at, "occurred_at");
445
+ if (requested == null) requested = _now();
446
+
447
+ var latest = await _readLatestEventTs(customerId);
448
+ var ts = _resolveOccurredAt(requested, latest);
449
+
450
+ var id = _b().uuid.v7();
451
+ await query(
452
+ "INSERT INTO email_engagement_events " +
453
+ "(id, customer_id, event_type, occurred_at) VALUES (?1, ?2, ?3, ?4)",
454
+ [id, customerId, eventType, ts],
455
+ );
456
+
457
+ // Refresh the denormalized summary in lockstep so getScore /
458
+ // unengagedCustomers / metricsForBand all answer from a single-
459
+ // row read on the hot path.
460
+ var summary = await _recomputeOne(customerId, _now());
461
+
462
+ return {
463
+ id: id,
464
+ customer_id: customerId,
465
+ event_type: eventType,
466
+ occurred_at: ts,
467
+ score: summary.score,
468
+ band: summary.band,
469
+ };
470
+ },
471
+
472
+ getScore: async function (customerId) {
473
+ _uuid(customerId, "customer_id");
474
+ var summary = await _readSummary(customerId);
475
+ if (!summary) {
476
+ // Customer never had an event — return the neutral starting
477
+ // shape so the dashboard renders "no engagement data" the
478
+ // same way as "we recomputed and there's nothing." The
479
+ // starting score lands in the `engaged` band by design (a
480
+ // fresh address has no reason to be excluded from the
481
+ // welcome series).
482
+ return _hydrate({
483
+ customer_id: customerId,
484
+ score: STARTING_SCORE,
485
+ band: _bandFor(STARTING_SCORE),
486
+ last_opened_at: null,
487
+ last_clicked_at: null,
488
+ send_count: 0,
489
+ open_count: 0,
490
+ click_count: 0,
491
+ computed_at: null,
492
+ });
493
+ }
494
+ return _hydrate(summary);
495
+ },
496
+
497
+ recompute: async function (customerId) {
498
+ _uuid(customerId, "customer_id");
499
+ var summary = await _recomputeOne(customerId, _now());
500
+ return _hydrate(summary);
501
+ },
502
+
503
+ // Refresh every summary whose `computed_at` is older than the
504
+ // operator-supplied cutoff OR who has an event recorded since the
505
+ // cutoff. The integrator wires this on a cron so a customer
506
+ // accumulating not_opened_in_window decay events keeps drifting
507
+ // toward `unengaged` without waiting for a fresh positive signal
508
+ // to force a recompute.
509
+ recomputeAll: async function (input) {
510
+ if (!input || typeof input !== "object") {
511
+ throw new TypeError("emailEngagementScore.recomputeAll: input object required");
512
+ }
513
+ var since = _epochMs(input.since, "since");
514
+ if (since == null) {
515
+ throw new TypeError("emailEngagementScore.recomputeAll: since is required");
516
+ }
517
+ var r = await query(
518
+ "SELECT DISTINCT customer_id FROM email_engagement_scores WHERE computed_at < ?1 " +
519
+ "UNION " +
520
+ "SELECT DISTINCT customer_id FROM email_engagement_events WHERE occurred_at >= ?1",
521
+ [since],
522
+ );
523
+ var now = _now();
524
+ var rows = r.rows;
525
+ var refreshed = [];
526
+ for (var i = 0; i < rows.length; i += 1) {
527
+ var summary = await _recomputeOne(rows[i].customer_id, now);
528
+ refreshed.push(_hydrate(summary));
529
+ }
530
+ return { recomputed_count: refreshed.length, recomputed_at: now };
531
+ },
532
+
533
+ // Customers under or at a band cap, ordered by score ascending
534
+ // (lowest-engagement first) so the operator's "who do we
535
+ // re-engage / who do we drop" review starts at the worst
536
+ // offenders. `band_max` is INCLUSIVE — `band_max: "lapsed"`
537
+ // returns both lapsed AND unengaged customers (everything at-or-
538
+ // below the lapsed tier).
539
+ unengagedCustomers: async function (input) {
540
+ if (!input || typeof input !== "object") {
541
+ throw new TypeError("emailEngagementScore.unengagedCustomers: input object required");
542
+ }
543
+ var bandMax = _band(input.band_max);
544
+ var limit = _limit(input.limit);
545
+
546
+ // Score ceiling for the inclusive band cap.
547
+ var ceiling;
548
+ switch (bandMax) {
549
+ case "unengaged": ceiling = BANDS.UNENGAGED_MAX; break;
550
+ case "lapsed": ceiling = BANDS.LAPSED_MAX; break;
551
+ case "engaged": ceiling = BANDS.ENGAGED_MAX; break;
552
+ case "highly_engaged": ceiling = 100; break;
553
+ default:
554
+ // _band above is exhaustive over BAND_NAMES, but the eslint
555
+ // default-case rule wants an explicit fallthrough — keep the
556
+ // throw so a future band addition refuses loud instead of
557
+ // returning accidentally-empty results.
558
+ throw new TypeError("emailEngagementScore.unengagedCustomers: unhandled band " +
559
+ JSON.stringify(bandMax));
560
+ }
561
+
562
+ var r = await query(
563
+ "SELECT customer_id, score, band, last_opened_at, last_clicked_at, " +
564
+ "send_count, open_count, click_count, computed_at " +
565
+ "FROM email_engagement_scores WHERE score <= ?1 " +
566
+ "ORDER BY score ASC, customer_id ASC LIMIT ?2",
567
+ [ceiling, limit],
568
+ );
569
+ return r.rows.map(_hydrate);
570
+ },
571
+
572
+ // Aggregate counters + average score for one band over an optional
573
+ // [from, to] window on `computed_at`. `from` / `to` are optional —
574
+ // absent, the entire band is in scope. The operator's KPI
575
+ // dashboard reads this to chart band-membership drift over time.
576
+ metricsForBand: async function (input) {
577
+ if (!input || typeof input !== "object") {
578
+ throw new TypeError("emailEngagementScore.metricsForBand: input object required");
579
+ }
580
+ var band = _band(input.band);
581
+ var from = _epochMs(input.from, "from");
582
+ var to = _epochMs(input.to, "to");
583
+ if (from != null && to != null && to < from) {
584
+ throw new TypeError("emailEngagementScore.metricsForBand: to must be >= from");
585
+ }
586
+
587
+ var sql = "SELECT COUNT(*) AS n, COALESCE(AVG(score), 0) AS avg_score, " +
588
+ "COALESCE(SUM(send_count), 0) AS sends, " +
589
+ "COALESCE(SUM(open_count), 0) AS opens, " +
590
+ "COALESCE(SUM(click_count), 0) AS clicks " +
591
+ "FROM email_engagement_scores WHERE band = ?1";
592
+ var params = [band];
593
+ var slot = 2;
594
+ if (from != null) { sql += " AND computed_at >= ?" + slot; params.push(from); slot += 1; }
595
+ if (to != null) { sql += " AND computed_at <= ?" + slot; params.push(to); slot += 1; }
596
+
597
+ var row = (await query(sql, params)).rows[0];
598
+ var count = Number(row.n);
599
+ var sends = Number(row.sends || 0);
600
+ var opens = Number(row.opens || 0);
601
+ var clicks = Number(row.clicks || 0);
602
+ return {
603
+ band: band,
604
+ customer_count: count,
605
+ // AVG returns 0 on an empty band; surface null so the
606
+ // dashboard distinguishes "the band is empty" from "every
607
+ // customer in the band scored 0".
608
+ avg_score: count === 0 ? null : Number(row.avg_score),
609
+ send_count: sends,
610
+ open_count: opens,
611
+ click_count: clicks,
612
+ open_rate: _rate(opens, sends),
613
+ click_rate: _rate(clicks, sends),
614
+ };
615
+ },
616
+
617
+ historyForCustomer: async function (customerId, listOpts) {
618
+ _uuid(customerId, "customer_id");
619
+ listOpts = listOpts || {};
620
+ var from = _epochMs(listOpts.from, "from");
621
+ var to = _epochMs(listOpts.to, "to");
622
+ var limit = listOpts.limit == null ? MAX_LIST_LIMIT : _limit(listOpts.limit);
623
+ if (from != null && to != null && to < from) {
624
+ throw new TypeError("emailEngagementScore.historyForCustomer: to must be >= from");
625
+ }
626
+
627
+ var sql = "SELECT id, customer_id, event_type, occurred_at " +
628
+ "FROM email_engagement_events WHERE customer_id = ?1";
629
+ var params = [customerId];
630
+ var slot = 2;
631
+ if (from != null) { sql += " AND occurred_at >= ?" + slot; params.push(from); slot += 1; }
632
+ if (to != null) { sql += " AND occurred_at <= ?" + slot; params.push(to); slot += 1; }
633
+ sql += " ORDER BY occurred_at DESC LIMIT ?" + slot;
634
+ params.push(limit);
635
+
636
+ var r = await query(sql, params);
637
+ return r.rows;
638
+ },
639
+ };
640
+ }
641
+
642
+ module.exports = {
643
+ create: create,
644
+ EVENT_TYPES: EVENT_TYPES,
645
+ BAND_NAMES: BAND_NAMES,
646
+ BANDS: BANDS,
647
+ WEIGHTS: WEIGHTS,
648
+ STARTING_SCORE: STARTING_SCORE,
649
+ };