@blamejs/blamejs-shop 0.0.62 → 0.0.65

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.
@@ -0,0 +1,850 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.recommendations
4
+ * @title Recommendations — operator-curated overrides + signal-based picks
5
+ *
6
+ * @intro
7
+ * Storefront product-recommendation engine. Powers "You might also
8
+ * like" rails on the product page, "Frequently bought together" on
9
+ * the cart, "Picked for you" on the customer home, and "More in
10
+ * this collection" on category pages.
11
+ *
12
+ * Two layers, in order:
13
+ *
14
+ * 1. Operator-curated override layer. Operators pin specific
15
+ * product → recommendation pairs ("when viewing product A,
16
+ * always show product B"). Overrides are kind-scoped (the
17
+ * same A can have different recommendations on the PDP rail
18
+ * vs. the cart rail) and ordered by weight DESC, position ASC
19
+ * so a hand-curated rail renders in operator-controlled order.
20
+ *
21
+ * 2. Signal-based fallback layer. When overrides don't fill the
22
+ * requested count, the picker falls through to an algorithmic
23
+ * signal selected per `kind`:
24
+ *
25
+ * - `product` → co-purchase (other products bought in the
26
+ * same orders as the source product), then
27
+ * category-popular (popular products in the
28
+ * source product's collection), then random
29
+ * in-stock as the last-resort filler.
30
+ * - `cart` → co-purchase aggregated across every product
31
+ * id in the cart, then category-popular by
32
+ * the dominant collection, then random in-stock.
33
+ * - `customer` → recently-viewed `recommend` (co-view signal,
34
+ * composed when the operator wires the
35
+ * `recentlyViewed` handle), then category-
36
+ * popular from the customer's most-viewed
37
+ * collection, then random in-stock.
38
+ * - `category` → category-popular within the slug, then
39
+ * random in-stock from the collection.
40
+ *
41
+ * Every signal-stage candidate is filtered for archived / out-of-
42
+ * stock products at render time — the override layer is operator-
43
+ * orphan-tolerant by design (an override pointing at an archived
44
+ * product is silently dropped from the rail, not hard-failed,
45
+ * because re-publishing the product re-enables it).
46
+ *
47
+ * Composes:
48
+ * - `b.guardUuid` — every product / customer / cart id is UUID-
49
+ * shape-validated at the entry point.
50
+ * - `b.crypto.namespaceHash` — `session_id` on ledger writes is
51
+ * hashed under the `recommendations-session` namespace so the
52
+ * raw cookie never lands in the events table.
53
+ * - `b.uuid.v7` — row ids for both overrides and events.
54
+ * - `opts.catalog` — required, used for product / variant /
55
+ * stock lookups during signal stages and the random-in-stock
56
+ * filler.
57
+ * - `opts.recentlyViewed` — optional, composed by
58
+ * `recommendForCustomer` when wired so the customer-personalized
59
+ * rail can lean on the co-view signal.
60
+ * - `opts.analytics` — accepted for forward symmetry with the
61
+ * sibling storefront primitives. Not strictly used today — the
62
+ * co-purchase signal reads `order_lines` directly so the
63
+ * primitive works on operators that haven't enabled the
64
+ * analytics event stream — but the option is wired so a
65
+ * future "boost picks the analytics dashboard already
66
+ * surfaces" pass has the dependency in place without a
67
+ * signature change.
68
+ *
69
+ * Surface:
70
+ * - `recommendForProduct(product_id, { limit?, kind? = "product",
71
+ * exclude_ids?, fillFromFallback? })`
72
+ * → `[{ product_id, source: "override" | "co_purchase" |
73
+ * "category_popular" | "in_stock_random",
74
+ * weight?, position?, score? }]`
75
+ * - `recommendForCart(input, opts?)` where input is either an
76
+ * array of product ids or `{ product_ids }` — kind=cart.
77
+ * - `recommendForCustomer(customer_id, opts?)` — kind=customer.
78
+ * - `recommendForCategory(slug, opts?)` — kind=category.
79
+ * - `setOverride({ kind, source_id, recommended_product_id,
80
+ * weight?, position? })` — UPSERT the active row
81
+ * for the (kind, source_id, recommended_product_id) tuple,
82
+ * reviving an archived row if one exists.
83
+ * - `removeOverride({ kind, source_id, recommended_product_id })`
84
+ * — soft-delete via `archived_at = now`. The unique index
85
+ * filters non-archived only, so the same triple can be re-added
86
+ * later without dropping the audit row.
87
+ * - `listOverrides({ kind, source_id?, includeArchived? = false })`
88
+ * — active overrides for the kind (+ optional source_id),
89
+ * ordered weight DESC, position ASC.
90
+ * - `recordImpression({ kind, source_id, recommended_id,
91
+ * session_id?, occurred_at? })`
92
+ * - `recordClick({ kind, source_id, recommended_id, session_id?,
93
+ * occurred_at? })`
94
+ * - `recordConversion({ kind, source_id, recommended_id,
95
+ * session_id?, order_id?, occurred_at? })`
96
+ * - `metricsForKind(kind, { source_id?, since?, until? })`
97
+ * → `{ kind, source_id?, since, until, impressions, clicks,
98
+ * conversions, ctr, conversion_rate, revenue_minor_by_currency }`
99
+ *
100
+ * Storage:
101
+ * - `recommendation_overrides` + `recommendation_events`
102
+ * (migration `0105_recommendations.sql`).
103
+ *
104
+ * @primitive recommendations
105
+ * @related b.guardUuid, b.crypto.namespaceHash, b.uuid,
106
+ * shop.recentlyViewed, shop.analytics, shop.catalog
107
+ */
108
+
109
+ var DEFAULT_LIMIT = 8;
110
+ var MAX_LIMIT = 50;
111
+ var MAX_CART_PRODUCTS = 50;
112
+ var DEFAULT_WEIGHT = 100;
113
+ var MAX_WEIGHT = 1000000;
114
+ var ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
115
+ var DEFAULT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
116
+ var KINDS = ["product", "cart", "customer", "category"];
117
+ var EVENT_TYPES = ["impression", "click", "conversion"];
118
+ var SESSION_NAMESPACE = "recommendations-session";
119
+ var SESSION_ID_RE = /^[A-Za-z0-9_-]{16,64}$/;
120
+ var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,198}[a-z0-9])?$/;
121
+
122
+ var bShop;
123
+ function _b() {
124
+ if (!bShop) bShop = require("./index");
125
+ return bShop.framework;
126
+ }
127
+
128
+ // ---- validators ---------------------------------------------------------
129
+
130
+ function _kind(k) {
131
+ if (typeof k !== "string" || KINDS.indexOf(k) === -1) {
132
+ throw new TypeError(
133
+ "recommendations: kind must be one of (" + KINDS.join(", ") +
134
+ "), got " + JSON.stringify(k)
135
+ );
136
+ }
137
+ return k;
138
+ }
139
+
140
+ function _eventType(t) {
141
+ if (typeof t !== "string" || EVENT_TYPES.indexOf(t) === -1) {
142
+ throw new TypeError(
143
+ "recommendations: event_type must be one of (" +
144
+ EVENT_TYPES.join(", ") + "), got " + JSON.stringify(t)
145
+ );
146
+ }
147
+ return t;
148
+ }
149
+
150
+ function _uuid(s, label) {
151
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
152
+ catch (e) {
153
+ throw new TypeError(
154
+ "recommendations: " + label + " — " + (e && e.message || "invalid UUID")
155
+ );
156
+ }
157
+ }
158
+
159
+ function _slug(s, label) {
160
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
161
+ throw new TypeError(
162
+ "recommendations: " + label +
163
+ " must be a lowercase alnum + dash slug, 1..200 chars"
164
+ );
165
+ }
166
+ return s;
167
+ }
168
+
169
+ // `product` / `cart` / `customer` kinds anchor source_id on a UUID;
170
+ // `category` anchors on a collection slug.
171
+ function _sourceId(kind, s, label) {
172
+ if (kind === "category") return _slug(s, label);
173
+ return _uuid(s, label);
174
+ }
175
+
176
+ function _limit(n, label) {
177
+ if (n == null) return DEFAULT_LIMIT;
178
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIMIT) {
179
+ throw new TypeError(
180
+ "recommendations: " + label + " must be an integer 1.." + MAX_LIMIT
181
+ );
182
+ }
183
+ return n;
184
+ }
185
+
186
+ function _weight(n) {
187
+ if (n == null) return DEFAULT_WEIGHT;
188
+ if (!Number.isInteger(n) || n < 0 || n > MAX_WEIGHT) {
189
+ throw new TypeError(
190
+ "recommendations: weight must be a non-negative integer ≤ " + MAX_WEIGHT
191
+ );
192
+ }
193
+ return n;
194
+ }
195
+
196
+ function _position(n) {
197
+ if (n == null) return 0;
198
+ if (!Number.isInteger(n) || n < 0) {
199
+ throw new TypeError(
200
+ "recommendations: position must be a non-negative integer"
201
+ );
202
+ }
203
+ return n;
204
+ }
205
+
206
+ function _occurredAt(v) {
207
+ if (v == null) return Date.now();
208
+ if (!Number.isInteger(v) || v < 0) {
209
+ throw new TypeError(
210
+ "recommendations: occurred_at must be a non-negative integer (epoch ms)"
211
+ );
212
+ }
213
+ return v;
214
+ }
215
+
216
+ function _epochMs(n, label) {
217
+ if (!Number.isInteger(n) || n < 0) {
218
+ throw new TypeError(
219
+ "recommendations: " + label +
220
+ " must be a non-negative integer (epoch ms)"
221
+ );
222
+ }
223
+ return n;
224
+ }
225
+
226
+ function _resolveWindow(opts) {
227
+ opts = opts || {};
228
+ var now = Date.now();
229
+ var since = opts.since == null ? (now - DEFAULT_WINDOW_MS) : opts.since;
230
+ var until = opts.until == null ? now : opts.until;
231
+ _epochMs(since, "since");
232
+ _epochMs(until, "until");
233
+ if (since >= until) {
234
+ throw new TypeError(
235
+ "recommendations: since must be strictly less than until"
236
+ );
237
+ }
238
+ if ((until - since) > ONE_YEAR_MS) {
239
+ throw new TypeError(
240
+ "recommendations: window (until - since) must be ≤ 1 year"
241
+ );
242
+ }
243
+ return { since: since, until: until };
244
+ }
245
+
246
+ function _sessionId(s) {
247
+ if (typeof s !== "string" || !SESSION_ID_RE.test(s)) {
248
+ throw new TypeError(
249
+ "recommendations: session_id must be 16-64 chars of [A-Za-z0-9_-]"
250
+ );
251
+ }
252
+ return s;
253
+ }
254
+
255
+ function _hashSession(s) {
256
+ return _b().crypto.namespaceHash(SESSION_NAMESPACE, s);
257
+ }
258
+
259
+ // ---- factory ------------------------------------------------------------
260
+
261
+ function create(opts) {
262
+ opts = opts || {};
263
+ if (typeof opts !== "object") {
264
+ throw new TypeError("recommendations.create: opts must be an object");
265
+ }
266
+ if (!opts.catalog) {
267
+ throw new TypeError(
268
+ "recommendations.create: opts.catalog required (composes catalog " +
269
+ "for product / variant / stock resolution)"
270
+ );
271
+ }
272
+ var query = opts.query;
273
+ if (!query) {
274
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
275
+ }
276
+ // The catalog handle is required at create-time (the factory
277
+ // refusal above guards it), but the picker reads `products` /
278
+ // `variants` / `inventory` via raw SQL through the same `query`
279
+ // function — leaning on the catalog handle's higher-level surface
280
+ // would force every operator to wire one for what is fundamentally
281
+ // a join against tables the schema already owns. The handle is
282
+ // kept on the closure so a future pass that needs catalog.products.
283
+ // get(id) for a render-time enrichment doesn't break the factory
284
+ // signature.
285
+ var _catalog = opts.catalog;
286
+ var recentlyViewed = opts.recentlyViewed || null;
287
+ // The analytics handle is accepted for parity with sibling
288
+ // storefront primitives. The co-purchase signal reads order_lines
289
+ // directly so operators that haven't enabled the analytics event
290
+ // stream still get useful picks; the option is here so a future
291
+ // "boost picks the dashboard surfaces" pass doesn't break the
292
+ // factory signature.
293
+ var _analytics = opts.analytics || null;
294
+
295
+ // Filter a candidate list down to active (non-archived) products
296
+ // that have at least one in-stock variant. The override layer is
297
+ // operator-orphan-tolerant — overrides pointing at archived /
298
+ // out-of-stock products are silently dropped from the rail.
299
+ async function _filterRenderable(productIds, excludeSet) {
300
+ if (!productIds.length) return [];
301
+ excludeSet = excludeSet || {};
302
+ var placeholders = productIds.map(function (_id, i) { return "?" + (i + 1); }).join(", ");
303
+ // Active products only (the `archived` status value is the
304
+ // catalog's soft-delete signal — there's no archived_at column
305
+ // on the products table).
306
+ var prodSql =
307
+ "SELECT id FROM products WHERE id IN (" + placeholders + ") " +
308
+ "AND status = 'active'";
309
+ var prodRows = (await query(prodSql, productIds)).rows;
310
+ var activeIds = {};
311
+ for (var i = 0; i < prodRows.length; i += 1) activeIds[prodRows[i].id] = true;
312
+ // At least one variant with stock_on_hand > 0.
313
+ var stockSql =
314
+ "SELECT DISTINCT v.product_id AS pid FROM variants v " +
315
+ "JOIN inventory i ON i.sku = v.sku " +
316
+ "WHERE v.product_id IN (" + placeholders + ") AND i.stock_on_hand > 0";
317
+ var stockRows = (await query(stockSql, productIds)).rows;
318
+ var inStock = {};
319
+ for (var j = 0; j < stockRows.length; j += 1) inStock[stockRows[j].pid] = true;
320
+ var out = [];
321
+ for (var k = 0; k < productIds.length; k += 1) {
322
+ var pid = productIds[k];
323
+ if (excludeSet[pid]) continue;
324
+ if (!activeIds[pid]) continue;
325
+ if (!inStock[pid]) continue;
326
+ out.push(pid);
327
+ }
328
+ return out;
329
+ }
330
+
331
+ // Look up the source product's primary collection (the membership
332
+ // with the lowest position). Used as the category-popular pivot
333
+ // when the override + co-purchase stages haven't filled the rail.
334
+ async function _primaryCollectionFor(productId) {
335
+ var row = (await query(
336
+ "SELECT collection_slug FROM collection_members WHERE product_id = ?1 " +
337
+ "ORDER BY position ASC, id ASC LIMIT 1",
338
+ [productId],
339
+ )).rows[0];
340
+ return row ? row.collection_slug : null;
341
+ }
342
+
343
+ // Co-purchase signal: for each source product id, find every other
344
+ // product id that appeared in the same order, count co-occurrences,
345
+ // sort desc. The fan-out is bounded by the per-source LIMIT on the
346
+ // join — operators with extreme order volumes can pass a tighter
347
+ // `limit` to recommendForProduct / recommendForCart.
348
+ async function _coPurchase(sourceProductIds, sliceLimit) {
349
+ if (!sourceProductIds.length) return [];
350
+ // SQLite reuses numbered placeholders — `?1..?N` bind the source
351
+ // ids once and the same numbers reappear in the NOT IN clause to
352
+ // exclude the seeds. The LIMIT binds to `?{N+1}`.
353
+ var placeholders = sourceProductIds.map(function (_id, i) { return "?" + (i + 1); }).join(", ");
354
+ var limitPlaceholder = "?" + (sourceProductIds.length + 1);
355
+ var sql =
356
+ "SELECT v2.product_id AS pid, COUNT(*) AS hits FROM order_lines ol1 " +
357
+ "JOIN variants v1 ON v1.id = ol1.variant_id " +
358
+ "JOIN order_lines ol2 ON ol2.order_id = ol1.order_id AND ol2.variant_id != ol1.variant_id " +
359
+ "JOIN variants v2 ON v2.id = ol2.variant_id " +
360
+ "WHERE v1.product_id IN (" + placeholders + ") " +
361
+ " AND v2.product_id NOT IN (" + placeholders + ") " +
362
+ "GROUP BY v2.product_id " +
363
+ "ORDER BY hits DESC, v2.product_id ASC " +
364
+ "LIMIT " + limitPlaceholder;
365
+ var params = sourceProductIds.slice();
366
+ params.push(sliceLimit);
367
+ var rows = (await query(sql, params)).rows;
368
+ return rows.map(function (r) {
369
+ return { product_id: r.pid, score: Number(r.hits) };
370
+ });
371
+ }
372
+
373
+ // Category-popular signal: most-ordered products within a
374
+ // collection, by sum of order_line qty. Falls back to membership
375
+ // position when the collection has no order history yet so a
376
+ // brand-new category still renders.
377
+ async function _categoryPopular(collectionSlug, sliceLimit) {
378
+ if (!collectionSlug) return [];
379
+ var rows = (await query(
380
+ "SELECT cm.product_id AS pid, COALESCE(SUM(ol.qty), 0) AS sales " +
381
+ "FROM collection_members cm " +
382
+ "LEFT JOIN variants v ON v.product_id = cm.product_id " +
383
+ "LEFT JOIN order_lines ol ON ol.variant_id = v.id " +
384
+ "WHERE cm.collection_slug = ?1 " +
385
+ "GROUP BY cm.product_id " +
386
+ "ORDER BY sales DESC, cm.position ASC, cm.id ASC " +
387
+ "LIMIT ?2",
388
+ [collectionSlug, sliceLimit],
389
+ )).rows;
390
+ return rows.map(function (r) {
391
+ return { product_id: r.pid, score: Number(r.sales) };
392
+ });
393
+ }
394
+
395
+ // Random in-stock filler. Last-resort source so the rail always
396
+ // renders SOMETHING even on a fresh storefront with no order
397
+ // history. Uses RANDOM() for the ordering — pure presentation
398
+ // randomness, no security implication.
399
+ async function _randomInStock(sliceLimit) {
400
+ var rows = (await query(
401
+ "SELECT DISTINCT v.product_id AS pid FROM variants v " +
402
+ "JOIN inventory i ON i.sku = v.sku " +
403
+ "JOIN products p ON p.id = v.product_id " +
404
+ "WHERE i.stock_on_hand > 0 AND p.status = 'active' " +
405
+ "ORDER BY RANDOM() " +
406
+ "LIMIT ?1",
407
+ [sliceLimit],
408
+ )).rows;
409
+ return rows.map(function (r) {
410
+ return { product_id: r.pid, score: 0 };
411
+ });
412
+ }
413
+
414
+ // Read the active override layer for a (kind, source_id) tuple.
415
+ async function _activeOverrides(kind, sourceId) {
416
+ return (await query(
417
+ "SELECT * FROM recommendation_overrides " +
418
+ "WHERE kind = ?1 AND source_id = ?2 AND archived_at IS NULL " +
419
+ "ORDER BY weight DESC, position ASC, id ASC",
420
+ [kind, sourceId],
421
+ )).rows;
422
+ }
423
+
424
+ // Compose the override + signal stages into a single ordered list
425
+ // bounded by `limit`. Each pick carries a `source` tag so the
426
+ // caller can render "operator-picked" vs. "algorithmic" badges.
427
+ async function _composeRail(kind, sourceId, signalStages, listOpts) {
428
+ listOpts = listOpts || {};
429
+ var limit = _limit(listOpts.limit, "limit");
430
+ var excludeIds = Array.isArray(listOpts.exclude_ids) ? listOpts.exclude_ids : [];
431
+ var excludeSet = {};
432
+ for (var e = 0; e < excludeIds.length; e += 1) excludeSet[excludeIds[e]] = true;
433
+ var fill = listOpts.fillFromFallback !== false;
434
+
435
+ var seen = {};
436
+ var out = [];
437
+
438
+ // Stage 1: overrides.
439
+ var ov = await _activeOverrides(kind, sourceId);
440
+ var ovIds = ov.map(function (r) { return r.recommended_product_id; });
441
+ var renderableOv = await _filterRenderable(ovIds, excludeSet);
442
+ var renderableOvSet = {};
443
+ for (var i = 0; i < renderableOv.length; i += 1) renderableOvSet[renderableOv[i]] = true;
444
+ for (var j = 0; j < ov.length && out.length < limit; j += 1) {
445
+ var row = ov[j];
446
+ if (!renderableOvSet[row.recommended_product_id]) continue;
447
+ if (seen[row.recommended_product_id]) continue;
448
+ seen[row.recommended_product_id] = true;
449
+ out.push({
450
+ product_id: row.recommended_product_id,
451
+ source: "override",
452
+ weight: Number(row.weight),
453
+ position: Number(row.position),
454
+ });
455
+ }
456
+
457
+ if (!fill) return out;
458
+
459
+ // Stage 2+: signal stages, in declared order.
460
+ for (var s = 0; s < signalStages.length && out.length < limit; s += 1) {
461
+ var stage = signalStages[s];
462
+ var candidates = await stage.run(limit * 3);
463
+ var candidateIds = candidates.map(function (c) { return c.product_id; });
464
+ var filtered = await _filterRenderable(candidateIds, Object.assign({}, excludeSet, seen));
465
+ var filteredSet = {};
466
+ for (var f = 0; f < filtered.length; f += 1) filteredSet[filtered[f]] = true;
467
+ for (var m = 0; m < candidates.length && out.length < limit; m += 1) {
468
+ var cand = candidates[m];
469
+ if (!filteredSet[cand.product_id]) continue;
470
+ if (seen[cand.product_id]) continue;
471
+ seen[cand.product_id] = true;
472
+ out.push({
473
+ product_id: cand.product_id,
474
+ source: stage.name,
475
+ score: cand.score,
476
+ });
477
+ }
478
+ }
479
+
480
+ return out;
481
+ }
482
+
483
+ // ---- public surface ---------------------------------------------------
484
+
485
+ var api = {
486
+ DEFAULT_LIMIT: DEFAULT_LIMIT,
487
+ MAX_LIMIT: MAX_LIMIT,
488
+ KINDS: KINDS.slice(),
489
+ EVENT_TYPES: EVENT_TYPES.slice(),
490
+ DEFAULT_WEIGHT: DEFAULT_WEIGHT,
491
+ DEFAULT_WINDOW_MS: DEFAULT_WINDOW_MS,
492
+
493
+ // "You might also like" rail anchored on a single PDP product.
494
+ recommendForProduct: async function (productId, listOpts) {
495
+ var pid = _uuid(productId, "product_id");
496
+ listOpts = listOpts || {};
497
+ if (typeof listOpts !== "object") {
498
+ throw new TypeError("recommendations.recommendForProduct: opts must be an object");
499
+ }
500
+ var kind = listOpts.kind == null ? "product" : _kind(listOpts.kind);
501
+ // Self-exclusion: never recommend a product to itself.
502
+ var exclude = (Array.isArray(listOpts.exclude_ids) ? listOpts.exclude_ids : []).concat([pid]);
503
+ var primary = await _primaryCollectionFor(pid);
504
+ var stages = [
505
+ { name: "co_purchase", run: async function (n) { return _coPurchase([pid], n); } },
506
+ { name: "category_popular", run: async function (n) { return _categoryPopular(primary, n); } },
507
+ { name: "in_stock_random", run: async function (n) { return _randomInStock(n); } },
508
+ ];
509
+ return _composeRail(kind, pid, stages, Object.assign({}, listOpts, { exclude_ids: exclude }));
510
+ },
511
+
512
+ // "Frequently bought together" — cart-rail picker. Input is
513
+ // either an array of product ids or `{ product_ids }`. The
514
+ // primitive aggregates the co-purchase signal across every
515
+ // product in the cart and pivots category-popular off the cart's
516
+ // dominant collection. The cart's anchor source_id for the
517
+ // override layer is the FIRST product id (operators curate cart
518
+ // overrides anchored on the most-likely-to-appear primary item).
519
+ recommendForCart: async function (input, listOpts) {
520
+ var productIds;
521
+ if (Array.isArray(input)) productIds = input;
522
+ else if (input && Array.isArray(input.product_ids)) productIds = input.product_ids;
523
+ else throw new TypeError("recommendations.recommendForCart: input must be product id array or { product_ids }");
524
+ if (productIds.length === 0) {
525
+ throw new TypeError("recommendations.recommendForCart: at least one product_id required");
526
+ }
527
+ if (productIds.length > MAX_CART_PRODUCTS) {
528
+ throw new TypeError(
529
+ "recommendations.recommendForCart: cart product_ids capped at " + MAX_CART_PRODUCTS
530
+ );
531
+ }
532
+ var validated = productIds.map(function (p, i) {
533
+ return _uuid(p, "product_ids[" + i + "]");
534
+ });
535
+ listOpts = listOpts || {};
536
+ if (typeof listOpts !== "object") {
537
+ throw new TypeError("recommendations.recommendForCart: opts must be an object");
538
+ }
539
+ var anchor = validated[0];
540
+ var primary = await _primaryCollectionFor(anchor);
541
+ var exclude = (Array.isArray(listOpts.exclude_ids) ? listOpts.exclude_ids : []).concat(validated);
542
+ var stages = [
543
+ { name: "co_purchase", run: async function (n) { return _coPurchase(validated, n); } },
544
+ { name: "category_popular", run: async function (n) { return _categoryPopular(primary, n); } },
545
+ { name: "in_stock_random", run: async function (n) { return _randomInStock(n); } },
546
+ ];
547
+ return _composeRail("cart", anchor, stages, Object.assign({}, listOpts, { exclude_ids: exclude }));
548
+ },
549
+
550
+ // Customer-personalized home rail. When `opts.recentlyViewed` is
551
+ // wired, the customer's co-view signal drives the first
552
+ // algorithmic stage; otherwise the rail falls straight through
553
+ // to category-popular from the customer's most-purchased
554
+ // collection.
555
+ recommendForCustomer: async function (customerId, listOpts) {
556
+ var cid = _uuid(customerId, "customer_id");
557
+ listOpts = listOpts || {};
558
+ if (typeof listOpts !== "object") {
559
+ throw new TypeError("recommendations.recommendForCustomer: opts must be an object");
560
+ }
561
+ // Pivot category for fallback — the collection the customer
562
+ // has bought from most.
563
+ var pivotRow = (await query(
564
+ "SELECT cm.collection_slug AS slug, COUNT(*) AS hits FROM order_lines ol " +
565
+ "JOIN orders o ON o.id = ol.order_id " +
566
+ "JOIN variants v ON v.id = ol.variant_id " +
567
+ "JOIN collection_members cm ON cm.product_id = v.product_id " +
568
+ "WHERE o.customer_id = ?1 " +
569
+ "GROUP BY cm.collection_slug " +
570
+ "ORDER BY hits DESC LIMIT 1",
571
+ [cid],
572
+ )).rows[0];
573
+ var primary = pivotRow ? pivotRow.slug : null;
574
+
575
+ var stages = [];
576
+ if (recentlyViewed && typeof recentlyViewed.recommend === "function") {
577
+ stages.push({
578
+ name: "co_view",
579
+ run: async function (_n) {
580
+ var recs = await recentlyViewed.recommend(cid, { limit: MAX_LIMIT });
581
+ return recs.map(function (r) { return { product_id: r.product_id, score: r.score }; });
582
+ },
583
+ });
584
+ }
585
+ stages.push({ name: "category_popular", run: async function (n) { return _categoryPopular(primary, n); } });
586
+ stages.push({ name: "in_stock_random", run: async function (n) { return _randomInStock(n); } });
587
+
588
+ return _composeRail("customer", cid, stages, listOpts);
589
+ },
590
+
591
+ // Category-page picker. Source_id is the collection slug.
592
+ recommendForCategory: async function (slug, listOpts) {
593
+ var s = _slug(slug, "slug");
594
+ listOpts = listOpts || {};
595
+ if (typeof listOpts !== "object") {
596
+ throw new TypeError("recommendations.recommendForCategory: opts must be an object");
597
+ }
598
+ var stages = [
599
+ { name: "category_popular", run: async function (n) { return _categoryPopular(s, n); } },
600
+ { name: "in_stock_random", run: async function (n) { return _randomInStock(n); } },
601
+ ];
602
+ return _composeRail("category", s, stages, listOpts);
603
+ },
604
+
605
+ // Operator override: UPSERT the active row for the (kind,
606
+ // source_id, recommended_product_id) tuple. If an archived row
607
+ // exists for the same triple it's revived (archived_at = NULL,
608
+ // weight + position re-applied); the unique index filters
609
+ // non-archived only so the audit row for a prior add/remove
610
+ // cycle stays in place.
611
+ setOverride: async function (input) {
612
+ if (!input || typeof input !== "object") {
613
+ throw new TypeError("recommendations.setOverride: input object required");
614
+ }
615
+ var kind = _kind(input.kind);
616
+ var sourceId = _sourceId(kind, input.source_id, "source_id");
617
+ var recommended = _uuid(input.recommended_product_id, "recommended_product_id");
618
+ var weight = _weight(input.weight);
619
+ var position = _position(input.position);
620
+ var now = _occurredAt(input.occurred_at);
621
+
622
+ // Reusing the recommended product as its own source on the
623
+ // `product` / `cart` kinds is a silent no-op trap (operators
624
+ // accidentally pin "show A when viewing A"). Refuse loudly so
625
+ // the curation UI can surface the error.
626
+ if ((kind === "product" || kind === "cart") && sourceId === recommended) {
627
+ throw new TypeError(
628
+ "recommendations.setOverride: source_id and recommended_product_id " +
629
+ "must differ for kind=" + kind
630
+ );
631
+ }
632
+
633
+ // Look up the most recent row for the triple — active or
634
+ // archived — and either revive / update it (preserving the id
635
+ // and audit trail) or insert a fresh row.
636
+ var existing = (await query(
637
+ "SELECT * FROM recommendation_overrides " +
638
+ "WHERE kind = ?1 AND source_id = ?2 AND recommended_product_id = ?3 " +
639
+ "ORDER BY archived_at IS NULL DESC, created_at DESC LIMIT 1",
640
+ [kind, sourceId, recommended],
641
+ )).rows[0];
642
+
643
+ if (existing) {
644
+ await query(
645
+ "UPDATE recommendation_overrides SET " +
646
+ "weight = ?1, position = ?2, archived_at = NULL, updated_at = ?3 " +
647
+ "WHERE id = ?4",
648
+ [weight, position, now, existing.id],
649
+ );
650
+ return {
651
+ id: existing.id,
652
+ kind: kind,
653
+ source_id: sourceId,
654
+ recommended_product_id: recommended,
655
+ weight: weight,
656
+ position: position,
657
+ status: "updated",
658
+ };
659
+ }
660
+
661
+ var id = _b().uuid.v7();
662
+ await query(
663
+ "INSERT INTO recommendation_overrides " +
664
+ "(id, kind, source_id, recommended_product_id, weight, position, archived_at, created_at, updated_at) " +
665
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?7)",
666
+ [id, kind, sourceId, recommended, weight, position, now],
667
+ );
668
+ return {
669
+ id: id,
670
+ kind: kind,
671
+ source_id: sourceId,
672
+ recommended_product_id: recommended,
673
+ weight: weight,
674
+ position: position,
675
+ status: "inserted",
676
+ };
677
+ },
678
+
679
+ // Soft-delete an override. Sets archived_at; the unique active-
680
+ // override index filters archived rows so the same triple can be
681
+ // re-added later via setOverride (which revives the same row).
682
+ removeOverride: async function (input) {
683
+ if (!input || typeof input !== "object") {
684
+ throw new TypeError("recommendations.removeOverride: input object required");
685
+ }
686
+ var kind = _kind(input.kind);
687
+ var sourceId = _sourceId(kind, input.source_id, "source_id");
688
+ var recommended = _uuid(input.recommended_product_id, "recommended_product_id");
689
+ var now = _occurredAt(input.occurred_at);
690
+ var r = await query(
691
+ "UPDATE recommendation_overrides SET archived_at = ?1, updated_at = ?1 " +
692
+ "WHERE kind = ?2 AND source_id = ?3 AND recommended_product_id = ?4 " +
693
+ " AND archived_at IS NULL",
694
+ [now, kind, sourceId, recommended],
695
+ );
696
+ return { removed: Number(r.rowCount || 0) };
697
+ },
698
+
699
+ // List active overrides for a kind. `source_id` is optional —
700
+ // omit it to enumerate every active row for the kind (operator
701
+ // dashboard view). `includeArchived` flips the filter so an
702
+ // operator can audit removed picks.
703
+ listOverrides: async function (listOpts) {
704
+ if (!listOpts || typeof listOpts !== "object") {
705
+ throw new TypeError("recommendations.listOverrides: opts object required");
706
+ }
707
+ var kind = _kind(listOpts.kind);
708
+ var includeArchived = listOpts.includeArchived === true;
709
+ if (listOpts.source_id != null) {
710
+ var sourceId = _sourceId(kind, listOpts.source_id, "source_id");
711
+ var sql =
712
+ "SELECT * FROM recommendation_overrides " +
713
+ "WHERE kind = ?1 AND source_id = ?2 " +
714
+ (includeArchived ? "" : "AND archived_at IS NULL ") +
715
+ "ORDER BY weight DESC, position ASC, id ASC";
716
+ return (await query(sql, [kind, sourceId])).rows;
717
+ }
718
+ var sql2 =
719
+ "SELECT * FROM recommendation_overrides WHERE kind = ?1 " +
720
+ (includeArchived ? "" : "AND archived_at IS NULL ") +
721
+ "ORDER BY source_id ASC, weight DESC, position ASC, id ASC";
722
+ return (await query(sql2, [kind])).rows;
723
+ },
724
+
725
+ // ---- ledger writes -------------------------------------------------
726
+
727
+ recordImpression: async function (input) {
728
+ return _recordEvent(input, "impression");
729
+ },
730
+ recordClick: async function (input) {
731
+ return _recordEvent(input, "click");
732
+ },
733
+ recordConversion: async function (input) {
734
+ return _recordEvent(input, "conversion");
735
+ },
736
+
737
+ // ---- metrics report -----------------------------------------------
738
+
739
+ // CTR + conversion-rate aggregate for the operator dashboard.
740
+ // The window defaults to the last 30 days; the same gates as
741
+ // analytics apply (epoch ms integers, since < until, ≤ 1 year).
742
+ // When `source_id` is supplied the report narrows to that
743
+ // surface; omit it for a kind-wide aggregate.
744
+ metricsForKind: async function (kind, metricsOpts) {
745
+ var k = _kind(kind);
746
+ metricsOpts = metricsOpts || {};
747
+ if (typeof metricsOpts !== "object") {
748
+ throw new TypeError("recommendations.metricsForKind: opts must be an object");
749
+ }
750
+ var w = _resolveWindow(metricsOpts);
751
+ var params = [k, w.since, w.until];
752
+ var sourceClause = "";
753
+ if (metricsOpts.source_id != null) {
754
+ var sourceId = _sourceId(k, metricsOpts.source_id, "source_id");
755
+ params.push(sourceId);
756
+ sourceClause = "AND source_id = ?4 ";
757
+ }
758
+ var rows = (await query(
759
+ "SELECT event_type, COUNT(*) AS cnt FROM recommendation_events " +
760
+ "WHERE kind = ?1 AND occurred_at >= ?2 AND occurred_at <= ?3 " +
761
+ sourceClause +
762
+ "GROUP BY event_type",
763
+ params,
764
+ )).rows;
765
+ var counts = { impression: 0, click: 0, conversion: 0 };
766
+ for (var i = 0; i < rows.length; i += 1) {
767
+ counts[rows[i].event_type] = Number(rows[i].cnt);
768
+ }
769
+ // Revenue attribution: SUM grand_total_minor of orders linked
770
+ // by conversion-row order_id, grouped by currency so multi-
771
+ // currency catalogs don't collapse incompatible figures.
772
+ var revenueRows = (await query(
773
+ "SELECT o.currency AS currency, SUM(o.grand_total_minor) AS revenue " +
774
+ "FROM recommendation_events re " +
775
+ "JOIN orders o ON o.id = re.order_id " +
776
+ "WHERE re.kind = ?1 AND re.event_type = 'conversion' " +
777
+ " AND re.occurred_at >= ?2 AND re.occurred_at <= ?3 " +
778
+ (metricsOpts.source_id != null ? "AND re.source_id = ?4 " : "") +
779
+ "GROUP BY o.currency",
780
+ params,
781
+ )).rows;
782
+ var revenue = {};
783
+ for (var j = 0; j < revenueRows.length; j += 1) {
784
+ revenue[revenueRows[j].currency] = Number(revenueRows[j].revenue || 0);
785
+ }
786
+ var ctr = counts.impression === 0 ? 0 : counts.click / counts.impression;
787
+ var cr = counts.click === 0 ? 0 : counts.conversion / counts.click;
788
+ var out = {
789
+ kind: k,
790
+ since: w.since,
791
+ until: w.until,
792
+ impressions: counts.impression,
793
+ clicks: counts.click,
794
+ conversions: counts.conversion,
795
+ ctr: ctr,
796
+ conversion_rate: cr,
797
+ revenue_minor_by_currency: revenue,
798
+ };
799
+ if (metricsOpts.source_id != null) out.source_id = params[3];
800
+ return out;
801
+ },
802
+ };
803
+
804
+ // Shared ledger writer for impression / click / conversion. The
805
+ // session_id is namespace-hashed at the entry point so the raw
806
+ // cookie never reaches the column. `order_id` is silently dropped
807
+ // on impression / click rows — the schema's nullable column
808
+ // already permits absence, and refusing here would force the
809
+ // operator to branch their hook code.
810
+ async function _recordEvent(input, eventType) {
811
+ if (!input || typeof input !== "object") {
812
+ throw new TypeError("recommendations.record" + _capitalize(eventType) + ": input object required");
813
+ }
814
+ var et = _eventType(eventType);
815
+ var kind = _kind(input.kind);
816
+ var sourceId = _sourceId(kind, input.source_id, "source_id");
817
+ var recommended = _uuid(input.recommended_id, "recommended_id");
818
+ var sessionHash = null;
819
+ if (input.session_id != null && input.session_id !== "") {
820
+ sessionHash = _hashSession(_sessionId(input.session_id));
821
+ }
822
+ var orderId = null;
823
+ if (et === "conversion" && input.order_id != null && input.order_id !== "") {
824
+ orderId = _uuid(input.order_id, "order_id");
825
+ }
826
+ var ts = _occurredAt(input.occurred_at);
827
+ var id = _b().uuid.v7();
828
+ await query(
829
+ "INSERT INTO recommendation_events " +
830
+ "(id, kind, source_id, recommended_id, session_id_hash, event_type, order_id, occurred_at) " +
831
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
832
+ [id, kind, sourceId, recommended, sessionHash, et, orderId, ts],
833
+ );
834
+ return { id: id, occurred_at: ts, event_type: et };
835
+ }
836
+
837
+ function _capitalize(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
838
+
839
+ return api;
840
+ }
841
+
842
+ module.exports = {
843
+ create: create,
844
+ DEFAULT_LIMIT: DEFAULT_LIMIT,
845
+ MAX_LIMIT: MAX_LIMIT,
846
+ KINDS: KINDS.slice(),
847
+ EVENT_TYPES: EVENT_TYPES.slice(),
848
+ DEFAULT_WEIGHT: DEFAULT_WEIGHT,
849
+ DEFAULT_WINDOW_MS: DEFAULT_WINDOW_MS,
850
+ };