@blamejs/blamejs-shop 0.0.66 → 0.0.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/lib/assembly-instructions.js +777 -0
  3. package/lib/auto-replenish.js +933 -0
  4. package/lib/click-and-collect.js +711 -0
  5. package/lib/clickstream.js +713 -0
  6. package/lib/customer-activity.js +862 -0
  7. package/lib/customer-notes.js +712 -0
  8. package/lib/customer-risk-profile.js +593 -0
  9. package/lib/customer-surveys.js +1012 -0
  10. package/lib/damage-photos.js +473 -0
  11. package/lib/dropship-forwarding.js +645 -0
  12. package/lib/email-templates.js +817 -0
  13. package/lib/index.js +35 -0
  14. package/lib/inventory-allocations.js +559 -0
  15. package/lib/inventory-writeoffs.js +636 -0
  16. package/lib/knowledge-base.js +1104 -0
  17. package/lib/locale-router.js +1077 -0
  18. package/lib/operator-roles.js +768 -0
  19. package/lib/order-escalation.js +951 -0
  20. package/lib/order-ratings.js +495 -0
  21. package/lib/order-tags.js +944 -0
  22. package/lib/packing-slips.js +810 -0
  23. package/lib/pixel-events.js +995 -0
  24. package/lib/print-queue.js +681 -0
  25. package/lib/product-qa.js +749 -0
  26. package/lib/promo-bundles.js +835 -0
  27. package/lib/push-notifications.js +937 -0
  28. package/lib/refund-automation.js +853 -0
  29. package/lib/reorder-reminders.js +798 -0
  30. package/lib/robots-config.js +753 -0
  31. package/lib/seller-signup.js +1052 -0
  32. package/lib/sitemap-generator.js +717 -0
  33. package/lib/subscription-gifts.js +710 -0
  34. package/lib/tax-cert-renewals.js +632 -0
  35. package/lib/tier-benefits.js +776 -0
  36. package/lib/vendor/MANIFEST.json +2 -2
  37. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  38. package/lib/vendor/blamejs/api-snapshot.json +2 -2
  39. package/lib/vendor/blamejs/lib/metrics.js +68 -4
  40. package/lib/vendor/blamejs/package.json +1 -1
  41. package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
  42. package/lib/wishlist-alerts.js +842 -0
  43. package/lib/wishlist-sharing.js +718 -0
  44. package/package.json +1 -1
@@ -0,0 +1,593 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.customerRiskProfile
4
+ * @title Customer risk profile — long-running per-customer signal
5
+ * aggregation that qualifies B2B credit, gates VIP perks, and
6
+ * flags accounts for hand-review.
7
+ *
8
+ * @intro
9
+ * Distinct from `fraudScreen` (migration 0038) which scores a single
10
+ * order draft pre-payment. The risk profile is the long-term picture
11
+ * built from observed bad signals over time:
12
+ *
13
+ * - chargeback (severe: a card-network reversal landed)
14
+ * - chargeback_dispute_lost (severe: representment came back against us)
15
+ * - fraud_score_high (per-order fraudScreen scored above the step-up threshold)
16
+ * - refund_request (a refund was requested, not necessarily fraud)
17
+ * - refund_to_credit (refund issued as store credit instead of cash — refund-policy verdict)
18
+ * - late_payment (B2B credit account paid past payment_terms_days)
19
+ * - address_mismatch (order's shipping country differed from billing country)
20
+ * - device_diversity_high (this account has logged in from an unusually wide device fingerprint set)
21
+ *
22
+ * `severity` is operator-chosen on a 1..10 scale per signal — the
23
+ * integrator picks a calibration that fits their risk appetite. The
24
+ * recent-window default is 90 days: `risk_score` is the sum of
25
+ * severities of non-cleared signals occurring in the last 90 days.
26
+ * Lifetime counts are tracked too but don't feed the score (a
27
+ * 5-year-old chargeback shouldn't keep a recovered customer at
28
+ * `critical` forever).
29
+ *
30
+ * Risk bands (closed intervals on score):
31
+ *
32
+ * 0 .. 9 → low
33
+ * 10 .. 24 → moderate
34
+ * 25 .. 49 → high
35
+ * 50+ → critical
36
+ *
37
+ * The thresholds are exposed on `customerRiskProfile.BANDS` so the
38
+ * integrator's dashboard and the test suite assert against the same
39
+ * numbers the runtime uses.
40
+ *
41
+ * Composition surface:
42
+ *
43
+ * var risk = bShop.customerRiskProfile.create({ query: q });
44
+ * await risk.recordSignal({
45
+ * customer_id: cid,
46
+ * kind: "chargeback",
47
+ * severity: 10,
48
+ * detail_json: { order_id: oid, amount_minor: 12500 },
49
+ * });
50
+ * var prof = await risk.getProfile(cid);
51
+ * // { customer_id, risk_score, risk_band, recent_signals, lifetime_signals }
52
+ *
53
+ * The summary table is denormalized: `recordSignal` updates the
54
+ * summary row in lockstep so getProfile + tier + topRiskCustomers
55
+ * all answer from a single-row read. `recompute({ customer_id })`
56
+ * re-derives the summary from the signal log when an operator
57
+ * clears a signal or the integrator wants to age out signals past
58
+ * the recent-window boundary. `recomputeAll({ since })` walks every
59
+ * customer whose summary's `recomputed_at` is older than the cutoff
60
+ * so the dashboard's pre-rendered ordering doesn't drift.
61
+ *
62
+ * `clearSignal({ signal_id, reason, cleared_by })` is the operator-
63
+ * override entry point for false positives — sets cleared_at +
64
+ * cleared_by + cleared_reason, then recomputes the affected
65
+ * customer's summary so the band/score reflects the clearing
66
+ * immediately. Re-clearing an already-cleared signal refuses.
67
+ *
68
+ * Monotonic per-customer `occurred_at`: two writes against the same
69
+ * customer in the same millisecond would tie on `occurred_at` and
70
+ * make the "latest signal" read ambiguous in the `(customer_id,
71
+ * occurred_at DESC)` index. `_resolveOccurredAt` bumps the requested
72
+ * timestamp to `prior + 1` on collision, guaranteeing strict
73
+ * monotonicity (same discipline as credit_transactions).
74
+ *
75
+ * Surface:
76
+ * - recordSignal({ customer_id, kind, severity, detail_json?, occurred_at? })
77
+ * - getProfile(customer_id)
78
+ * - tier(customer_id)
79
+ * - historyForCustomer(customer_id)
80
+ * - recompute({ customer_id })
81
+ * - recomputeAll({ since })
82
+ * - topRiskCustomers({ limit, band? })
83
+ * - clearSignal({ signal_id, reason, cleared_by })
84
+ *
85
+ * Storage:
86
+ * - customer_risk_signals (migration 0142)
87
+ * - customer_risk_summaries (migration 0142)
88
+ *
89
+ * Optional handles (all injectable on create()):
90
+ * - query — D1-shaped async query function (required)
91
+ * - fraudScreen — used by callers to translate per-order
92
+ * fraudScreen verdicts into recordSignal calls;
93
+ * the primitive itself never reaches in
94
+ * - returns — caller correlator for refund_request signals
95
+ * - dunning — caller correlator for late_payment signals
96
+ * - creditLimits — caller correlator for late_payment signals
97
+ *
98
+ * The optional handles are stored for callers that compose this
99
+ * primitive with the upstream sources; this primitive does not call
100
+ * into them. Recording a signal is a single explicit operator
101
+ * action, never an implicit cross-primitive side effect.
102
+ *
103
+ * @primitive customerRiskProfile
104
+ * @related b.uuid.v7, b.guardUuid, shop.fraudScreen, shop.creditLimits, shop.dunning, shop.returns
105
+ */
106
+
107
+ var bShop;
108
+ function _b() {
109
+ if (!bShop) {
110
+ try {
111
+ bShop = require("./index");
112
+ } catch (_e) {
113
+ // Fallback path — the lib/index.js registry mutation is the
114
+ // operator's responsibility. The framework surface this primitive
115
+ // consumes (guardUuid, uuid) is stable; no shop-level peer is
116
+ // touched through `_b()`, so the bypass is semantically
117
+ // equivalent until the registry edit lands.
118
+ bShop = { framework: require("./vendor/blamejs") };
119
+ }
120
+ }
121
+ return bShop.framework;
122
+ }
123
+
124
+ // ---- constants ----------------------------------------------------------
125
+
126
+ var KINDS = [
127
+ "chargeback",
128
+ "refund_request",
129
+ "fraud_score_high",
130
+ "chargeback_dispute_lost",
131
+ "late_payment",
132
+ "address_mismatch",
133
+ "device_diversity_high",
134
+ "refund_to_credit",
135
+ ];
136
+
137
+ var BAND_NAMES = ["low", "moderate", "high", "critical"];
138
+
139
+ // Closed intervals on risk_score. Score 0..9 → low, 10..24 → moderate,
140
+ // 25..49 → high, 50+ → critical. Exposed so the test suite + the
141
+ // dashboard renderer pull the same numbers the runtime uses.
142
+ var BANDS = Object.freeze({
143
+ LOW_MAX: 9,
144
+ MODERATE_MAX: 24,
145
+ HIGH_MAX: 49,
146
+ });
147
+
148
+ // Recent-window — signals older than this don't contribute to the
149
+ // score (their lifetime count still reflects them). 90 days matches
150
+ // the typical card-network chargeback-evidence cycle: anything older
151
+ // has already gone through dispute and is settled.
152
+ var RECENT_WINDOW_MS = 90 * 86400 * 1000;
153
+
154
+ // detail_json byte ceiling. Operators routinely embed order ids,
155
+ // amounts, a few human-readable notes — 8 KiB after JSON-encoding
156
+ // leaves room for that without becoming a payload-smuggling channel.
157
+ var MAX_DETAIL_JSON_BYTES = 8192;
158
+
159
+ // Plain-text refuses control bytes on the short fields. cleared_by /
160
+ // cleared_reason are short correlation strings — log-injection bytes
161
+ // have no place in a one-line column.
162
+ var MAX_REF_LEN = 128;
163
+ var PRINTABLE_RE = /^[^\x00-\x1f\x7f]*$/;
164
+
165
+ // ---- validators ---------------------------------------------------------
166
+
167
+ function _uuid(s, label) {
168
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
169
+ catch (e) { throw new TypeError("customerRiskProfile: " + label + " — " + (e && e.message || "invalid UUID")); }
170
+ }
171
+
172
+ function _kind(s) {
173
+ if (typeof s !== "string" || KINDS.indexOf(s) === -1) {
174
+ throw new TypeError("customerRiskProfile: kind must be one of " + KINDS.join(", "));
175
+ }
176
+ return s;
177
+ }
178
+
179
+ function _severity(n) {
180
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 1 || n > 10) {
181
+ throw new TypeError("customerRiskProfile: severity must be an integer in [1, 10]");
182
+ }
183
+ return n;
184
+ }
185
+
186
+ function _epochMs(ts, label) {
187
+ if (ts == null) return null;
188
+ if (typeof ts !== "number" || !Number.isInteger(ts) || ts < 0) {
189
+ throw new TypeError("customerRiskProfile: " + label + " must be a non-negative integer epoch-ms");
190
+ }
191
+ return ts;
192
+ }
193
+
194
+ function _band(s) {
195
+ if (typeof s !== "string" || BAND_NAMES.indexOf(s) === -1) {
196
+ throw new TypeError("customerRiskProfile: band must be one of " + BAND_NAMES.join(", "));
197
+ }
198
+ return s;
199
+ }
200
+
201
+ function _ref(s, label, required) {
202
+ if (s == null) {
203
+ if (required) {
204
+ throw new TypeError("customerRiskProfile: " + label + " is required");
205
+ }
206
+ return null;
207
+ }
208
+ if (typeof s !== "string") {
209
+ throw new TypeError("customerRiskProfile: " + label + " must be a string");
210
+ }
211
+ if (!s.length) {
212
+ throw new TypeError("customerRiskProfile: " + label + " must be a non-empty string when provided");
213
+ }
214
+ if (s.length > MAX_REF_LEN) {
215
+ throw new TypeError("customerRiskProfile: " + label + " must be <= " + MAX_REF_LEN + " chars");
216
+ }
217
+ if (!PRINTABLE_RE.test(s)) {
218
+ throw new TypeError("customerRiskProfile: " + label + " must not contain control bytes");
219
+ }
220
+ return s;
221
+ }
222
+
223
+ function _detailJson(v) {
224
+ if (v == null) return null;
225
+ if (typeof v !== "object") {
226
+ throw new TypeError("customerRiskProfile: detail_json must be a plain object when provided");
227
+ }
228
+ var encoded;
229
+ try { encoded = JSON.stringify(v); }
230
+ catch (_e) { throw new TypeError("customerRiskProfile: detail_json must be JSON-serializable"); }
231
+ if (encoded == null) {
232
+ throw new TypeError("customerRiskProfile: detail_json must be JSON-serializable");
233
+ }
234
+ if (Buffer.byteLength(encoded, "utf8") > MAX_DETAIL_JSON_BYTES) {
235
+ throw new TypeError("customerRiskProfile: detail_json must be <= " + MAX_DETAIL_JSON_BYTES + " bytes when JSON-encoded");
236
+ }
237
+ return encoded;
238
+ }
239
+
240
+ function _positiveInt(n, label) {
241
+ if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
242
+ throw new TypeError("customerRiskProfile: " + label + " must be a positive integer");
243
+ }
244
+ return n;
245
+ }
246
+
247
+ function _now() { return Date.now(); }
248
+
249
+ function _bandFor(score) {
250
+ if (score <= BANDS.LOW_MAX) return "low";
251
+ if (score <= BANDS.MODERATE_MAX) return "moderate";
252
+ if (score <= BANDS.HIGH_MAX) return "high";
253
+ return "critical";
254
+ }
255
+
256
+ // ---- factory ------------------------------------------------------------
257
+
258
+ function create(opts) {
259
+ opts = opts || {};
260
+ var query = opts.query;
261
+ if (!query) {
262
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
263
+ }
264
+ // Optional caller-correlator handles. Stored so an integrator can
265
+ // recover them from the instance for orchestration; not consumed
266
+ // by this primitive directly. Recording a signal is always an
267
+ // explicit operator call, never an implicit cross-primitive
268
+ // side effect — that's how the test suite isolates each signal
269
+ // source and the dashboard can show the audit trail.
270
+ var fraudScreen = opts.fraudScreen || null;
271
+ var returns = opts.returns || null;
272
+ var dunning = opts.dunning || null;
273
+ var creditLimits = opts.creditLimits || null;
274
+
275
+ async function _readLatestSignalTs(customerId) {
276
+ var r = await query(
277
+ "SELECT occurred_at FROM customer_risk_signals " +
278
+ "WHERE customer_id = ?1 ORDER BY occurred_at DESC LIMIT 1",
279
+ [customerId],
280
+ );
281
+ return r.rows.length ? r.rows[0].occurred_at : null;
282
+ }
283
+
284
+ // Same monotonic-clock discipline as credit_transactions: two
285
+ // writes against the same customer in the same millisecond would
286
+ // tie on `occurred_at` and corrupt the `(customer_id, occurred_at
287
+ // DESC)` index ordering. Bump the second write to `prior + 1`.
288
+ function _resolveOccurredAt(requestedTs, latestTs) {
289
+ if (latestTs == null) return requestedTs;
290
+ if (requestedTs > latestTs) return requestedTs;
291
+ return latestTs + 1;
292
+ }
293
+
294
+ async function _readSummary(customerId) {
295
+ var r = await query(
296
+ "SELECT customer_id, risk_score, risk_band, lifetime_signal_count, recent_signal_count, recomputed_at " +
297
+ "FROM customer_risk_summaries WHERE customer_id = ?1 LIMIT 1",
298
+ [customerId],
299
+ );
300
+ return r.rows.length ? r.rows[0] : null;
301
+ }
302
+
303
+ // Recompute the denormalized summary row for one customer from the
304
+ // signal log. Pulls every non-cleared signal, partitions into
305
+ // recent-window (contributes to risk_score) vs older (lifetime
306
+ // count only), and upserts the summary row. Returns the fresh
307
+ // summary shape so callers don't re-read.
308
+ async function _recomputeOne(customerId, now) {
309
+ var cutoff = now - RECENT_WINDOW_MS;
310
+
311
+ var r = await query(
312
+ "SELECT severity, occurred_at FROM customer_risk_signals " +
313
+ "WHERE customer_id = ?1 AND cleared_at IS NULL",
314
+ [customerId],
315
+ );
316
+
317
+ var lifetimeCount = r.rows.length;
318
+ var recentCount = 0;
319
+ var score = 0;
320
+ for (var i = 0; i < r.rows.length; i += 1) {
321
+ var row = r.rows[i];
322
+ if (row.occurred_at >= cutoff) {
323
+ recentCount += 1;
324
+ score += row.severity;
325
+ }
326
+ }
327
+ var band = _bandFor(score);
328
+
329
+ var existing = await _readSummary(customerId);
330
+ if (existing) {
331
+ await query(
332
+ "UPDATE customer_risk_summaries SET risk_score = ?1, risk_band = ?2, " +
333
+ "lifetime_signal_count = ?3, recent_signal_count = ?4, recomputed_at = ?5 " +
334
+ "WHERE customer_id = ?6",
335
+ [score, band, lifetimeCount, recentCount, now, customerId],
336
+ );
337
+ } else {
338
+ await query(
339
+ "INSERT INTO customer_risk_summaries " +
340
+ "(customer_id, risk_score, risk_band, lifetime_signal_count, recent_signal_count, recomputed_at) " +
341
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
342
+ [customerId, score, band, lifetimeCount, recentCount, now],
343
+ );
344
+ }
345
+ return {
346
+ customer_id: customerId,
347
+ risk_score: score,
348
+ risk_band: band,
349
+ lifetime_signal_count: lifetimeCount,
350
+ recent_signal_count: recentCount,
351
+ recomputed_at: now,
352
+ };
353
+ }
354
+
355
+ return {
356
+ KINDS: KINDS.slice(),
357
+ BAND_NAMES: BAND_NAMES.slice(),
358
+ BANDS: { LOW_MAX: BANDS.LOW_MAX, MODERATE_MAX: BANDS.MODERATE_MAX, HIGH_MAX: BANDS.HIGH_MAX },
359
+ RECENT_WINDOW_MS: RECENT_WINDOW_MS,
360
+
361
+ // Stored optional handles — exposed read-only so an integrator
362
+ // can recover them off the instance for cross-primitive
363
+ // orchestration. The primitive itself never reaches in.
364
+ handles: {
365
+ fraudScreen: fraudScreen,
366
+ returns: returns,
367
+ dunning: dunning,
368
+ creditLimits: creditLimits,
369
+ },
370
+
371
+ recordSignal: async function (input) {
372
+ if (!input || typeof input !== "object") {
373
+ throw new TypeError("customerRiskProfile.recordSignal: input object required");
374
+ }
375
+ var customerId = _uuid(input.customer_id, "customer_id");
376
+ var kind = _kind(input.kind);
377
+ var severity = _severity(input.severity);
378
+ var detail = _detailJson(input.detail_json);
379
+ var requested = _epochMs(input.occurred_at, "occurred_at");
380
+ if (requested == null) requested = _now();
381
+
382
+ var latest = await _readLatestSignalTs(customerId);
383
+ var ts = _resolveOccurredAt(requested, latest);
384
+
385
+ var id = _b().uuid.v7();
386
+ await query(
387
+ "INSERT INTO customer_risk_signals " +
388
+ "(id, customer_id, kind, severity, detail_json, occurred_at, cleared_at, cleared_by, cleared_reason) " +
389
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, NULL, NULL)",
390
+ [id, customerId, kind, severity, detail, ts],
391
+ );
392
+
393
+ // Refresh the denormalized summary in lockstep so getProfile /
394
+ // tier / topRiskCustomers answer from a single-row read on the
395
+ // hot path.
396
+ var summary = await _recomputeOne(customerId, _now());
397
+
398
+ return {
399
+ id: id,
400
+ customer_id: customerId,
401
+ kind: kind,
402
+ severity: severity,
403
+ detail_json: detail,
404
+ occurred_at: ts,
405
+ risk_score: summary.risk_score,
406
+ risk_band: summary.risk_band,
407
+ };
408
+ },
409
+
410
+ getProfile: async function (customerId) {
411
+ _uuid(customerId, "customer_id");
412
+ var summary = await _readSummary(customerId);
413
+ if (!summary) {
414
+ // Customer never had a signal — return the conservative
415
+ // shape so the dashboard can render "no signals on file" the
416
+ // same way as "we recomputed and there's nothing." `risk_band`
417
+ // is `low` because score 0 lands in the low band; the
418
+ // bucketization is the same function the runtime uses
419
+ // elsewhere.
420
+ return {
421
+ customer_id: customerId,
422
+ risk_score: 0,
423
+ risk_band: "low",
424
+ lifetime_signal_count: 0,
425
+ recent_signal_count: 0,
426
+ recomputed_at: null,
427
+ };
428
+ }
429
+ return {
430
+ customer_id: summary.customer_id,
431
+ risk_score: summary.risk_score,
432
+ risk_band: summary.risk_band,
433
+ lifetime_signal_count: summary.lifetime_signal_count,
434
+ recent_signal_count: summary.recent_signal_count,
435
+ recomputed_at: summary.recomputed_at,
436
+ };
437
+ },
438
+
439
+ tier: async function (customerId) {
440
+ _uuid(customerId, "customer_id");
441
+ var summary = await _readSummary(customerId);
442
+ if (!summary) return "low";
443
+ return summary.risk_band;
444
+ },
445
+
446
+ historyForCustomer: async function (customerId) {
447
+ _uuid(customerId, "customer_id");
448
+ var r = await query(
449
+ "SELECT id, customer_id, kind, severity, detail_json, occurred_at, " +
450
+ "cleared_at, cleared_by, cleared_reason " +
451
+ "FROM customer_risk_signals WHERE customer_id = ?1 ORDER BY occurred_at DESC",
452
+ [customerId],
453
+ );
454
+ return r.rows.map(function (row) {
455
+ return {
456
+ id: row.id,
457
+ customer_id: row.customer_id,
458
+ kind: row.kind,
459
+ severity: row.severity,
460
+ detail_json: row.detail_json == null ? null : JSON.parse(row.detail_json),
461
+ occurred_at: row.occurred_at,
462
+ cleared_at: row.cleared_at,
463
+ cleared_by: row.cleared_by,
464
+ cleared_reason: row.cleared_reason,
465
+ };
466
+ });
467
+ },
468
+
469
+ recompute: async function (input) {
470
+ if (!input || typeof input !== "object") {
471
+ throw new TypeError("customerRiskProfile.recompute: input object required");
472
+ }
473
+ var customerId = _uuid(input.customer_id, "customer_id");
474
+ return _recomputeOne(customerId, _now());
475
+ },
476
+
477
+ // Refresh every summary whose `recomputed_at` is older than the
478
+ // operator-supplied cutoff. The integrator wires this on a cron
479
+ // so signals naturally age out of the recent-window as wall-clock
480
+ // advances — without the cron, a customer with a 90-day-old
481
+ // chargeback would keep showing the same score until a fresh
482
+ // signal forced a recompute.
483
+ recomputeAll: async function (input) {
484
+ if (!input || typeof input !== "object") {
485
+ throw new TypeError("customerRiskProfile.recomputeAll: input object required");
486
+ }
487
+ var since = _epochMs(input.since, "since");
488
+ if (since == null) {
489
+ throw new TypeError("customerRiskProfile.recomputeAll: since is required");
490
+ }
491
+ // Pick up every customer with EITHER a stale summary OR a
492
+ // signal recorded since the cutoff. The union ensures a
493
+ // never-summarized customer who's accumulated signals also
494
+ // surfaces — recomputeAll is the integrator's "reconcile
495
+ // everything observable" entry point.
496
+ var r = await query(
497
+ "SELECT DISTINCT customer_id FROM customer_risk_summaries WHERE recomputed_at < ?1 " +
498
+ "UNION " +
499
+ "SELECT DISTINCT customer_id FROM customer_risk_signals WHERE occurred_at >= ?1",
500
+ [since],
501
+ );
502
+ var now = _now();
503
+ var refreshed = [];
504
+ for (var i = 0; i < r.rows.length; i += 1) {
505
+ var customerId = r.rows[i].customer_id;
506
+ var summary = await _recomputeOne(customerId, now);
507
+ refreshed.push(summary);
508
+ }
509
+ return { recomputed_count: refreshed.length, recomputed_at: now };
510
+ },
511
+
512
+ topRiskCustomers: async function (input) {
513
+ if (!input || typeof input !== "object") {
514
+ throw new TypeError("customerRiskProfile.topRiskCustomers: input object required");
515
+ }
516
+ var limit = _positiveInt(input.limit, "limit");
517
+ var band = null;
518
+ if (input.band != null) band = _band(input.band);
519
+
520
+ var sql = "SELECT customer_id, risk_score, risk_band, lifetime_signal_count, recent_signal_count, recomputed_at " +
521
+ "FROM customer_risk_summaries";
522
+ var params = [];
523
+ if (band != null) {
524
+ sql += " WHERE risk_band = ?1";
525
+ params.push(band);
526
+ }
527
+ sql += " ORDER BY risk_score DESC, customer_id ASC";
528
+ // SQLite parameter slot for LIMIT — use a fresh slot index so
529
+ // the predicate parameter (if present) doesn't collide.
530
+ sql += " LIMIT ?" + (params.length + 1);
531
+ params.push(limit);
532
+
533
+ var r = await query(sql, params);
534
+ return r.rows;
535
+ },
536
+
537
+ clearSignal: async function (input) {
538
+ if (!input || typeof input !== "object") {
539
+ throw new TypeError("customerRiskProfile.clearSignal: input object required");
540
+ }
541
+ var signalId = _uuid(input.signal_id, "signal_id");
542
+ var reason = _ref(input.reason, "reason", true);
543
+ var clearedBy = _ref(input.cleared_by, "cleared_by", true);
544
+
545
+ var r = await query(
546
+ "SELECT id, customer_id, cleared_at FROM customer_risk_signals WHERE id = ?1 LIMIT 1",
547
+ [signalId],
548
+ );
549
+ if (!r.rows.length) {
550
+ var notFound = new Error("customerRiskProfile.clearSignal: signal " + JSON.stringify(signalId) + " not found");
551
+ notFound.code = "RISK_SIGNAL_NOT_FOUND";
552
+ throw notFound;
553
+ }
554
+ var row = r.rows[0];
555
+ if (row.cleared_at != null) {
556
+ var already = new Error("customerRiskProfile.clearSignal: signal " + JSON.stringify(signalId) + " already cleared");
557
+ already.code = "RISK_SIGNAL_ALREADY_CLEARED";
558
+ throw already;
559
+ }
560
+
561
+ var now = _now();
562
+ await query(
563
+ "UPDATE customer_risk_signals SET cleared_at = ?1, cleared_by = ?2, cleared_reason = ?3 " +
564
+ "WHERE id = ?4",
565
+ [now, clearedBy, reason, signalId],
566
+ );
567
+
568
+ // Cleared signals drop their contribution to the score
569
+ // immediately — recompute the affected customer's summary so
570
+ // the band/score reflect the clearing on the next dashboard
571
+ // read without waiting for a recomputeAll cron tick.
572
+ var summary = await _recomputeOne(row.customer_id, now);
573
+
574
+ return {
575
+ signal_id: signalId,
576
+ customer_id: row.customer_id,
577
+ cleared_at: now,
578
+ cleared_by: clearedBy,
579
+ cleared_reason: reason,
580
+ risk_score: summary.risk_score,
581
+ risk_band: summary.risk_band,
582
+ };
583
+ },
584
+ };
585
+ }
586
+
587
+ module.exports = {
588
+ create: create,
589
+ KINDS: KINDS,
590
+ BAND_NAMES: BAND_NAMES,
591
+ BANDS: BANDS,
592
+ RECENT_WINDOW_MS: RECENT_WINDOW_MS,
593
+ };