@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.
- package/CHANGELOG.md +8 -0
- package/lib/announcement-bar.js +753 -0
- package/lib/banner-ab-tests.js +806 -0
- package/lib/bin-locations.js +791 -0
- package/lib/blog-articles.js +1173 -0
- package/lib/carrier-accounts.js +805 -0
- package/lib/cart-recovery.js +1133 -0
- package/lib/category-navigation.js +934 -0
- package/lib/consent-ledger.js +539 -0
- package/lib/customer-impersonation.js +743 -0
- package/lib/customer-merge.js +879 -0
- package/lib/demand-forecast.js +1121 -0
- package/lib/dispute-resolution.js +886 -0
- package/lib/email-ab-tests.js +918 -0
- package/lib/email-engagement-score.js +649 -0
- package/lib/event-log.js +713 -0
- package/lib/fulfillment-sla.js +791 -0
- package/lib/index.js +41 -0
- package/lib/inventory-audits.js +852 -0
- package/lib/line-gift-wrap.js +430 -0
- package/lib/marketing-budget.js +792 -0
- package/lib/operator-activity-feed.js +977 -0
- package/lib/operator-approvals.js +942 -0
- package/lib/operator-help-center.js +1020 -0
- package/lib/operator-inbox.js +889 -0
- package/lib/operator-sessions.js +701 -0
- package/lib/order-exchanges.js +602 -0
- package/lib/product-compare.js +804 -0
- package/lib/pwa-manifest.js +1005 -0
- package/lib/referral-leaderboard.js +612 -0
- package/lib/sales-tax-filings.js +807 -0
- package/lib/search-ranking.js +859 -0
- package/lib/shipping-insurance.js +757 -0
- package/lib/shrinkage-report.js +1182 -0
- package/lib/sidebar-widgets.js +952 -0
- package/lib/smart-restocking.js +1048 -0
- package/lib/stock-receipts.js +834 -0
- package/lib/subscription-analytics.js +1032 -0
- package/lib/suggestion-box.js +921 -0
- package/lib/tax-remittance.js +625 -0
- package/lib/vendor-invoices.js +1021 -0
- package/lib/winback-campaigns.js +1350 -0
- package/lib/wishlist-digest.js +1133 -0
- 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
|
+
};
|