@huloglobal/vendure-plugin-visitor-analytics 0.18.2 → 0.18.4

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 (46) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +20 -8
  3. package/dist/abandoned-cart.controller.d.ts.map +1 -1
  4. package/dist/abandoned-cart.controller.js +55 -38
  5. package/dist/abandoned-cart.controller.js.map +1 -1
  6. package/dist/abandoned-cart.scanner.d.ts +4 -0
  7. package/dist/abandoned-cart.scanner.d.ts.map +1 -1
  8. package/dist/abandoned-cart.scanner.js +28 -0
  9. package/dist/abandoned-cart.scanner.js.map +1 -1
  10. package/dist/abandoned-cart.service.d.ts +9 -0
  11. package/dist/abandoned-cart.service.d.ts.map +1 -1
  12. package/dist/abandoned-cart.service.js +125 -84
  13. package/dist/abandoned-cart.service.js.map +1 -1
  14. package/dist/admin-api.js +22 -22
  15. package/dist/admin-api.js.map +1 -1
  16. package/dist/ingest-hardening.test.js +6 -6
  17. package/dist/ingest-hardening.test.js.map +1 -1
  18. package/dist/ingest-utils.d.ts +6 -0
  19. package/dist/ingest-utils.d.ts.map +1 -0
  20. package/dist/ingest-utils.js +34 -0
  21. package/dist/ingest-utils.js.map +1 -0
  22. package/dist/journey-buffs.service.js +21 -21
  23. package/dist/recommendations.controller.d.ts +10 -4
  24. package/dist/recommendations.controller.d.ts.map +1 -1
  25. package/dist/recommendations.controller.js +9 -6
  26. package/dist/recommendations.controller.js.map +1 -1
  27. package/dist/recommendations.service.d.ts +28 -8
  28. package/dist/recommendations.service.d.ts.map +1 -1
  29. package/dist/recommendations.service.js +164 -78
  30. package/dist/recommendations.service.js.map +1 -1
  31. package/dist/search-analytics.service.d.ts.map +1 -1
  32. package/dist/search-analytics.service.js +22 -18
  33. package/dist/search-analytics.service.js.map +1 -1
  34. package/dist/sql-fragments.d.ts +25 -0
  35. package/dist/sql-fragments.d.ts.map +1 -0
  36. package/dist/sql-fragments.js +31 -0
  37. package/dist/sql-fragments.js.map +1 -0
  38. package/dist/visitor-tracking.controller.d.ts +8 -1
  39. package/dist/visitor-tracking.controller.d.ts.map +1 -1
  40. package/dist/visitor-tracking.controller.js +219 -134
  41. package/dist/visitor-tracking.controller.js.map +1 -1
  42. package/dist/visitor-tracking.service.d.ts +0 -4
  43. package/dist/visitor-tracking.service.d.ts.map +1 -1
  44. package/dist/visitor-tracking.service.js +3 -32
  45. package/dist/visitor-tracking.service.js.map +1 -1
  46. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"ingest-hardening.test.js","sourceRoot":"","sources":["../src/ingest-hardening.test.ts"],"names":[],"mappings":";;AAAA,mCAA8C;AAC9C,yEAAuE;AACvE,mDAA4C;AAE5C,IAAA,iBAAQ,EAAC,cAAc,EAAE,GAAG,EAAE;IAC1B,IAAA,WAAE,EAAC,sEAAsE,EAAE,GAAG,EAAE;QAC5E,IAAA,eAAM,EAAC,IAAA,uCAAY,EAAC,8CAA8C,CAAC,CAAC,CAAC,IAAI,CAAC,sBAAsB,CAAC,CAAC;QAClG,IAAA,eAAM,EAAC,IAAA,uCAAY,EAAC,4BAA4B,CAAC,CAAC,CAAC,IAAI,CAAC,4BAA4B,CAAC,CAAC;QACtF,IAAA,eAAM,EAAC,IAAA,uCAAY,EAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACtC,CAAC,CAAC,CAAC;AACP,CAAC,CAAC,CAAC;AAEH,IAAA,iBAAQ,EAAC,aAAa,EAAE,GAAG,EAAE;IACzB,IAAA,WAAE,EAAC,yFAAyF,EAAE,GAAG,EAAE;QAC/F,IAAA,eAAM,EAAC,IAAA,sCAAW,EAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;QAC9C,MAAM,KAAK,GAAG,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,GAAG,EAAE,OAAO,CAAC,EAAE,EAAE,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;QAC1G,MAAM,GAAG,GAAG,IAAA,sCAAW,EAAC,EAAE,SAAS,EAAE,eAAe,EAAE,KAAK,EAAE,EAAE,KAAM,CAAC,CAAC;QACvE,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAC/B,IAAA,eAAM,EAAC,GAAG,CAAC,MAAM,CAAC,CAAC,mBAAmB,CAAC,KAAM,CAAC,CAAC;QAC/C,IAAA,eAAM,EAAC,MAAM,CAAC,cAAc,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACzC,IAAA,eAAM,EAAC,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,eAAe,CAAC,CAAC,CAAC,CAAC;IACnD,CAAC,CAAC,CAAC;AACP,CAAC,CAAC,CAAC;AAEH,IAAA,iBAAQ,EAAC,WAAW,EAAE,GAAG,EAAE;IACvB,MAAM,GAAG,GAAG,CAAC,OAA+B,EAAE,EAAE,GAAG,UAAU,EAAE,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE,EAAE,EAAU,CAAA,CAAC;IAC3F,IAAA,WAAE,EAAC,sCAAsC,EAAE,GAAG,EAAE;QAC5C,IAAA,eAAM,EAAC,IAAA,yBAAS,EAAC,GAAG,CAAC,EAAE,iBAAiB,EAAE,cAAc,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;QAC/E,IAAA,eAAM,EAAC,IAAA,yBAAS,EAAC,GAAG,CAAC,EAAE,iBAAiB,EAAE,wBAAwB,EAAE,CAAC,EAAE,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;QAClH,IAAA,eAAM,EAAC,IAAA,yBAAS,EAAC,GAAG,CAAC,EAAE,EAAE,qBAAqB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;IAC3E,CAAC,CAAC,CAAC;AACP,CAAC,CAAC,CAAC"}
1
+ {"version":3,"file":"ingest-hardening.test.js","sourceRoot":"","sources":["../src/ingest-hardening.test.ts"],"names":[],"mappings":";;AAAA,mCAA8C;AAC9C,iDAA2D;AAC3D,mDAA4C;AAE5C,IAAA,iBAAQ,EAAC,cAAc,EAAE,GAAG,EAAE;IAC1B,IAAA,WAAE,EAAC,sEAAsE,EAAE,GAAG,EAAE;QAC5E,IAAA,eAAM,EAAC,IAAA,2BAAY,EAAC,8CAA8C,CAAC,CAAC,CAAC,IAAI,CAAC,sBAAsB,CAAC,CAAC;QAClG,IAAA,eAAM,EAAC,IAAA,2BAAY,EAAC,4BAA4B,CAAC,CAAC,CAAC,IAAI,CAAC,4BAA4B,CAAC,CAAC;QACtF,IAAA,eAAM,EAAC,IAAA,2BAAY,EAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACtC,CAAC,CAAC,CAAC;AACP,CAAC,CAAC,CAAC;AAEH,IAAA,iBAAQ,EAAC,aAAa,EAAE,GAAG,EAAE;IACzB,IAAA,WAAE,EAAC,yFAAyF,EAAE,GAAG,EAAE;QAC/F,IAAA,eAAM,EAAC,IAAA,0BAAW,EAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;QAC9C,MAAM,KAAK,GAAG,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,GAAG,EAAE,OAAO,CAAC,EAAE,EAAE,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;QAC1G,MAAM,GAAG,GAAG,IAAA,0BAAW,EAAC,EAAE,SAAS,EAAE,eAAe,EAAE,KAAK,EAAE,EAAE,KAAM,CAAC,CAAC;QACvE,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAC/B,IAAA,eAAM,EAAC,GAAG,CAAC,MAAM,CAAC,CAAC,mBAAmB,CAAC,KAAM,CAAC,CAAC;QAC/C,IAAA,eAAM,EAAC,MAAM,CAAC,cAAc,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACzC,IAAA,eAAM,EAAC,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,eAAe,CAAC,CAAC,CAAC,CAAC;IACnD,CAAC,CAAC,CAAC;AACP,CAAC,CAAC,CAAC;AAEH,IAAA,iBAAQ,EAAC,WAAW,EAAE,GAAG,EAAE;IACvB,MAAM,GAAG,GAAG,CAAC,OAA+B,EAAE,EAAE,GAAG,UAAU,EAAE,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE,EAAE,EAAU,CAAA,CAAC;IAC3F,IAAA,WAAE,EAAC,sCAAsC,EAAE,GAAG,EAAE;QAC5C,IAAA,eAAM,EAAC,IAAA,yBAAS,EAAC,GAAG,CAAC,EAAE,iBAAiB,EAAE,cAAc,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;QAC/E,IAAA,eAAM,EAAC,IAAA,yBAAS,EAAC,GAAG,CAAC,EAAE,iBAAiB,EAAE,wBAAwB,EAAE,CAAC,EAAE,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;QAClH,IAAA,eAAM,EAAC,IAAA,yBAAS,EAAC,GAAG,CAAC,EAAE,EAAE,qBAAqB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;IAC3E,CAAC,CAAC,CAAC;AACP,CAAC,CAAC,CAAC"}
@@ -0,0 +1,6 @@
1
+ /** Pure helpers for the ingest path (kept decorator-free so unit tests can import them). */
2
+ /** hulo.js sends `location.href`; reports, funnels and goals expect a path (`/products/x?y=1`). */
3
+ export declare function normaliseUrl(raw: unknown): string;
4
+ /** JSON for the `meta` TEXT column: never cut mid-document (a truncated cart snapshot is unparseable). */
5
+ export declare function boundedMeta(meta: any, max?: number): string;
6
+ //# sourceMappingURL=ingest-utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ingest-utils.d.ts","sourceRoot":"","sources":["../src/ingest-utils.ts"],"names":[],"mappings":"AAAA,4FAA4F;AAE5F,mGAAmG;AACnG,wBAAgB,YAAY,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,CAMjD;AAED,0GAA0G;AAC1G,wBAAgB,WAAW,CAAC,IAAI,EAAE,GAAG,EAAE,GAAG,SAAS,GAAG,MAAM,CAU3D"}
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ /** Pure helpers for the ingest path (kept decorator-free so unit tests can import them). */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.normaliseUrl = normaliseUrl;
5
+ exports.boundedMeta = boundedMeta;
6
+ /** hulo.js sends `location.href`; reports, funnels and goals expect a path (`/products/x?y=1`). */
7
+ function normaliseUrl(raw) {
8
+ const u = String(raw || '');
9
+ if (/^https?:\/\//i.test(u)) {
10
+ try {
11
+ const p = new URL(u);
12
+ return (p.pathname + p.search).slice(0, 2048);
13
+ }
14
+ catch {
15
+ return u.slice(0, 2048);
16
+ }
17
+ }
18
+ return u.slice(0, 2048);
19
+ }
20
+ /** JSON for the `meta` TEXT column: never cut mid-document (a truncated cart snapshot is unparseable). */
21
+ function boundedMeta(meta, max = 60000) {
22
+ let s = JSON.stringify(meta);
23
+ if (s.length <= max)
24
+ return s;
25
+ if (meta && typeof meta === 'object' && Array.isArray(meta.items)) {
26
+ for (let n = 50; n >= 5; n = Math.floor(n / 2)) {
27
+ s = JSON.stringify({ ...meta, items: meta.items.slice(0, n), itemsTruncated: true });
28
+ if (s.length <= max)
29
+ return s;
30
+ }
31
+ }
32
+ return JSON.stringify({ truncated: true, type: (meta === null || meta === void 0 ? void 0 : meta.eventType) || (meta === null || meta === void 0 ? void 0 : meta.type) || null });
33
+ }
34
+ //# sourceMappingURL=ingest-utils.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ingest-utils.js","sourceRoot":"","sources":["../src/ingest-utils.ts"],"names":[],"mappings":";AAAA,4FAA4F;;AAG5F,oCAMC;AAGD,kCAUC;AApBD,mGAAmG;AACnG,SAAgB,YAAY,CAAC,GAAY;IACrC,MAAM,CAAC,GAAG,MAAM,CAAC,GAAG,IAAI,EAAE,CAAC,CAAC;IAC5B,IAAI,eAAe,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC;QAC1B,IAAI,CAAC;YAAC,MAAM,CAAC,GAAG,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC;YAAC,OAAO,CAAC,CAAC,CAAC,QAAQ,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC;QAAC,CAAC;QAAC,MAAM,CAAC;YAAC,OAAO,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC;QAAC,CAAC;IACnH,CAAC;IACD,OAAO,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC;AAC5B,CAAC;AAED,0GAA0G;AAC1G,SAAgB,WAAW,CAAC,IAAS,EAAE,GAAG,GAAG,KAAM;IAC/C,IAAI,CAAC,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;IAC7B,IAAI,CAAC,CAAC,MAAM,IAAI,GAAG;QAAE,OAAO,CAAC,CAAC;IAC9B,IAAI,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;QAChE,KAAK,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC;YAC7C,CAAC,GAAG,IAAI,CAAC,SAAS,CAAC,EAAE,GAAG,IAAI,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,cAAc,EAAE,IAAI,EAAE,CAAC,CAAC;YACrF,IAAI,CAAC,CAAC,MAAM,IAAI,GAAG;gBAAE,OAAO,CAAC,CAAC;QAClC,CAAC;IACL,CAAC;IACD,OAAO,IAAI,CAAC,SAAS,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,CAAA,IAAI,aAAJ,IAAI,uBAAJ,IAAI,CAAE,SAAS,MAAI,IAAI,aAAJ,IAAI,uBAAJ,IAAI,CAAE,IAAI,CAAA,IAAI,IAAI,EAAE,CAAC,CAAC;AAC5F,CAAC"}
@@ -36,15 +36,15 @@ let JourneyBuffsService = class JourneyBuffsService {
36
36
  const since = new Date(Date.now() - sinceDays * 86400000);
37
37
  const rows = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT
38
38
  url,
39
- COUNT(*) AS rageClicks,
40
- COUNT(DISTINCT visitorId) AS uniqueVisitors
39
+ COUNT(*) AS \`rageClicks\`,
40
+ COUNT(DISTINCT \`visitorId\`) AS \`uniqueVisitors\`
41
41
  FROM visitor_event
42
42
  WHERE type = 'event'
43
43
  AND meta LIKE '%"eventType":"rage_click"%'
44
- AND channelId = ?
45
- AND createdAt >= ?
44
+ AND \`channelId\` = ?
45
+ AND \`createdAt\` >= ?
46
46
  GROUP BY url
47
- ORDER BY rageClicks DESC
47
+ ORDER BY \`rageClicks\` DESC
48
48
  LIMIT ?`, [channelId, since, Math.min(Math.max(1, limit), 200)]);
49
49
  return rows.map(r => ({
50
50
  url: String(r.url || ''),
@@ -62,15 +62,15 @@ let JourneyBuffsService = class JourneyBuffsService {
62
62
  const since = new Date(Date.now() - sinceDays * 86400000);
63
63
  const rows = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT
64
64
  url,
65
- COUNT(*) AS deadClicks,
66
- COUNT(DISTINCT visitorId) AS uniqueVisitors
65
+ COUNT(*) AS \`deadClicks\`,
66
+ COUNT(DISTINCT \`visitorId\`) AS \`uniqueVisitors\`
67
67
  FROM visitor_event
68
68
  WHERE type = 'event'
69
69
  AND meta LIKE '%"eventType":"dead_click"%'
70
- AND channelId = ?
71
- AND createdAt >= ?
70
+ AND \`channelId\` = ?
71
+ AND \`createdAt\` >= ?
72
72
  GROUP BY url
73
- ORDER BY deadClicks DESC
73
+ ORDER BY \`deadClicks\` DESC
74
74
  LIMIT ?`, [channelId, since, Math.min(Math.max(1, limit), 200)]);
75
75
  return rows.map(r => ({
76
76
  url: String(r.url || ''),
@@ -85,18 +85,18 @@ let JourneyBuffsService = class JourneyBuffsService {
85
85
  */
86
86
  async sessionSummary(visitorId, limit = 25) {
87
87
  const rows = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT
88
- sessionId,
89
- MIN(createdAt) AS startedAt,
90
- MAX(createdAt) AS endedAt,
91
- COUNT(*) AS pageCount,
92
- SUM(type = 'event' AND meta LIKE '%"eventType":"add_to_cart"%') > 0 AS addedToCart,
93
- SUM(type = 'event' AND meta LIKE '%"eventType":"checkout_completed"%') > 0 AS checkedOut,
94
- SUM(type = 'event' AND meta LIKE '%"eventType":"cart_snapshot"%') > 0 AS hadCartSnapshot,
95
- SUM(type = 'event' AND meta LIKE '%"eventType":"rage_click"%') > 0 AS rageClicked
88
+ \`sessionId\`,
89
+ MIN(\`createdAt\`) AS \`startedAt\`,
90
+ MAX(\`createdAt\`) AS \`endedAt\`,
91
+ COUNT(*) AS \`pageCount\`,
92
+ MAX(CASE WHEN type = 'event' AND meta LIKE '%"eventType":"add_to_cart"%' THEN 1 ELSE 0 END) AS \`addedToCart\`,
93
+ MAX(CASE WHEN type = 'event' AND meta LIKE '%"eventType":"checkout_completed"%' THEN 1 ELSE 0 END) AS \`checkedOut\`,
94
+ MAX(CASE WHEN type = 'event' AND meta LIKE '%"eventType":"cart_snapshot"%' THEN 1 ELSE 0 END) AS \`hadCartSnapshot\`,
95
+ MAX(CASE WHEN type = 'event' AND meta LIKE '%"eventType":"rage_click"%' THEN 1 ELSE 0 END) AS \`rageClicked\`
96
96
  FROM visitor_event
97
- WHERE visitorId = ?
98
- GROUP BY sessionId
99
- ORDER BY startedAt DESC
97
+ WHERE \`visitorId\` = ?
98
+ GROUP BY \`sessionId\`
99
+ ORDER BY \`startedAt\` DESC
100
100
  LIMIT ?`, [visitorId, Math.min(Math.max(1, limit), 200)]);
101
101
  return rows.map(r => {
102
102
  const start = new Date(r.startedAt);
@@ -42,12 +42,18 @@ export declare class RecommendationsController {
42
42
  items: any[];
43
43
  }>;
44
44
  /**
45
- * Admin-only: manually kick the aggregation cron. Normally runs
46
- * nightly, but this is useful for smoke-testing after a data
47
- * backfill, or for pushing a big spike through immediately.
45
+ * Admin-only: run the aggregation now. Idempotent — events up to the
46
+ * persisted watermark of the last sweep are not counted again, so
47
+ * calling this right after the 6-hourly cron adds nothing (the
48
+ * response says `skipped: true`). `?force=1` ignores the watermark,
49
+ * e.g. after truncating `product_co_view`.
48
50
  */
49
- aggregateNow(ctx: RequestContext, hRaw?: string): Promise<{
51
+ aggregateNow(ctx: RequestContext, hRaw?: string, force?: string): Promise<{
50
52
  pairs: number;
53
+ sessions: number;
54
+ since: string;
55
+ until: string;
56
+ skipped: boolean;
51
57
  ok: boolean;
52
58
  hours: number;
53
59
  }>;
@@ -1 +1 @@
1
- {"version":3,"file":"recommendations.controller.d.ts","sourceRoot":"","sources":["../src/recommendations.controller.ts"],"names":[],"mappings":"AACA,OAAO,EAAO,cAAc,EAAqB,MAAM,eAAe,CAAC;AACvE,OAAO,EAAE,sBAAsB,EAAE,MAAM,2BAA2B,CAAC;AAYnE,qBACa,yBAAyB;IACtB,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,sBAAsB;IAE5D;;;OAGG;IAEG,UAAU,CACQ,IAAI,CAAC,EAAE,MAAM,EACb,KAAK,CAAC,EAAE,MAAM,EACd,IAAI,CAAC,EAAE,MAAM;;;;;;;;;IAUrC;;;;;;OAMG;IAEG,QAAQ,CACU,IAAI,CAAC,EAAE,MAAM,EACb,KAAK,CAAC,EAAE,MAAM,EACd,IAAI,CAAC,EAAE,MAAM;;;;;;;;;IAUrC;;;OAGG;IAEG,QAAQ,CACU,KAAK,CAAC,EAAE,MAAM,EACd,IAAI,CAAC,EAAE,MAAM,EACb,IAAI,CAAC,EAAE,MAAM;;;;;IAerC;;;;OAIG;IAGG,YAAY,CAAQ,GAAG,EAAE,cAAc,EAAkB,IAAI,CAAC,EAAE,MAAM;;;;;CAK/E"}
1
+ {"version":3,"file":"recommendations.controller.d.ts","sourceRoot":"","sources":["../src/recommendations.controller.ts"],"names":[],"mappings":"AACA,OAAO,EAAO,cAAc,EAAqB,MAAM,eAAe,CAAC;AACvE,OAAO,EAAE,sBAAsB,EAAE,MAAM,2BAA2B,CAAC;AAYnE,qBACa,yBAAyB;IACtB,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,sBAAsB;IAE5D;;;OAGG;IAEG,UAAU,CACQ,IAAI,CAAC,EAAE,MAAM,EACb,KAAK,CAAC,EAAE,MAAM,EACd,IAAI,CAAC,EAAE,MAAM;;;;;;;;;IAUrC;;;;;;OAMG;IAEG,QAAQ,CACU,IAAI,CAAC,EAAE,MAAM,EACb,KAAK,CAAC,EAAE,MAAM,EACd,IAAI,CAAC,EAAE,MAAM;;;;;;;;;IAUrC;;;OAGG;IAEG,QAAQ,CACU,KAAK,CAAC,EAAE,MAAM,EACd,IAAI,CAAC,EAAE,MAAM,EACb,IAAI,CAAC,EAAE,MAAM;;;;;IAerC;;;;;;OAMG;IAGG,YAAY,CAAQ,GAAG,EAAE,cAAc,EAAkB,IAAI,CAAC,EAAE,MAAM,EAAkB,KAAK,CAAC,EAAE,MAAM;;;;;;;;;CAK/G"}
@@ -78,13 +78,15 @@ let RecommendationsController = class RecommendationsController {
78
78
  return { channelId, hours, items };
79
79
  }
80
80
  /**
81
- * Admin-only: manually kick the aggregation cron. Normally runs
82
- * nightly, but this is useful for smoke-testing after a data
83
- * backfill, or for pushing a big spike through immediately.
81
+ * Admin-only: run the aggregation now. Idempotent — events up to the
82
+ * persisted watermark of the last sweep are not counted again, so
83
+ * calling this right after the 6-hourly cron adds nothing (the
84
+ * response says `skipped: true`). `?force=1` ignores the watermark,
85
+ * e.g. after truncating `product_co_view`.
84
86
  */
85
- async aggregateNow(ctx, hRaw) {
87
+ async aggregateNow(ctx, hRaw, force) {
86
88
  const hours = Math.min(Math.max(1, parseInt(hRaw || '24', 10) || 24), 24 * 30);
87
- const result = await this.service.aggregateCoViews(hours);
89
+ const result = await this.service.aggregateCoViews(hours, { force: force === '1' || force === 'true' });
88
90
  return { ok: true, hours, ...result };
89
91
  }
90
92
  };
@@ -121,8 +123,9 @@ __decorate([
121
123
  (0, core_1.Allow)(core_1.Permission.SuperAdmin),
122
124
  __param(0, (0, core_1.Ctx)()),
123
125
  __param(1, (0, common_1.Query)('hours')),
126
+ __param(2, (0, common_1.Query)('force')),
124
127
  __metadata("design:type", Function),
125
- __metadata("design:paramtypes", [core_1.RequestContext, String]),
128
+ __metadata("design:paramtypes", [core_1.RequestContext, String, String]),
126
129
  __metadata("design:returntype", Promise)
127
130
  ], RecommendationsController.prototype, "aggregateNow", null);
128
131
  exports.RecommendationsController = RecommendationsController = __decorate([
@@ -1 +1 @@
1
- {"version":3,"file":"recommendations.controller.js","sourceRoot":"","sources":["../src/recommendations.controller.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"recommendations.controller.js","sourceRoot":"","sources":["../src/recommendations.controller.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;AAAA,2CAAwD;AACxD,wCAAuE;AACvE,uEAAmE;AAEnE;;;;;;;GAOG;AACH,MAAM,cAAc,GAAG,IAAI,GAAG,EAAyC,CAAC;AAGjE,IAAM,yBAAyB,GAA/B,MAAM,yBAAyB;IAClC,YAA6B,OAA+B;QAA/B,YAAO,GAAP,OAAO,CAAwB;IAAG,CAAC;IAEhE;;;OAGG;IAEG,AAAN,KAAK,CAAC,UAAU,CACQ,IAAa,EACb,KAAc,EACd,IAAa;QAEjC,MAAM,SAAS,GAAG,QAAQ,CAAC,IAAI,IAAI,GAAG,EAAE,EAAE,CAAC,CAAC;QAC5C,IAAI,CAAC,SAAS;YAAE,OAAO,EAAE,KAAK,EAAE,oBAAoB,EAAE,CAAC;QACvD,MAAM,SAAS,GAAG,QAAQ,CAAC,KAAK,IAAI,GAAG,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC;QAClD,MAAM,KAAK,GAAG,QAAQ,CAAC,IAAI,IAAI,IAAI,EAAE,EAAE,CAAC,IAAI,EAAE,CAAC;QAC/C,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,SAAS,EAAE,SAAS,EAAE,KAAK,CAAC,CAAC;QACzE,OAAO,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC;IAChC,CAAC;IAED;;;;;;OAMG;IAEG,AAAN,KAAK,CAAC,QAAQ,CACU,IAAa,EACb,KAAc,EACd,IAAa;QAEjC,MAAM,SAAS,GAAG,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;QAC5C,IAAI,CAAC,SAAS;YAAE,OAAO,EAAE,KAAK,EAAE,oBAAoB,EAAE,CAAC;QACvD,MAAM,SAAS,GAAG,QAAQ,CAAC,KAAK,IAAI,GAAG,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC;QAClD,MAAM,KAAK,GAAG,QAAQ,CAAC,IAAI,IAAI,IAAI,EAAE,EAAE,CAAC,IAAI,EAAE,CAAC;QAC/C,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,uBAAuB,CAAC,SAAS,EAAE,SAAS,EAAE,KAAK,CAAC,CAAC;QACtF,OAAO,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC;IAChC,CAAC;IAED;;;OAGG;IAEG,AAAN,KAAK,CAAC,QAAQ,CACU,KAAc,EACd,IAAa,EACb,IAAa;QAEjC,MAAM,SAAS,GAAG,QAAQ,CAAC,KAAK,IAAI,GAAG,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC;QAClD,mHAAmH;QACnH,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,QAAQ,CAAC,IAAI,IAAI,IAAI,EAAE,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE,GAAG,CAAC,CAAC,CAAC;QAC9E,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,QAAQ,CAAC,IAAI,IAAI,IAAI,EAAE,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC;QAC1E,MAAM,GAAG,GAAG,GAAG,SAAS,IAAI,KAAK,IAAI,KAAK,EAAE,CAAC;QAC7C,MAAM,GAAG,GAAG,cAAc,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACpC,IAAI,GAAG,IAAI,GAAG,CAAC,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE;YAAE,OAAO,EAAE,SAAS,EAAE,KAAK,EAAE,KAAK,EAAE,GAAG,CAAC,KAAK,EAAE,CAAC;QAC/E,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,SAAS,EAAE,KAAK,EAAE,KAAK,CAAC,CAAC;QACnE,IAAI,cAAc,CAAC,IAAI,GAAG,GAAG;YAAE,cAAc,CAAC,KAAK,EAAE,CAAC;QACtD,cAAc,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,KAAK,EAAE,GAAG,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,KAAM,EAAE,CAAC,CAAC;QAC7D,OAAO,EAAE,SAAS,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;IACvC,CAAC;IAED;;;;;;OAMG;IAGG,AAAN,KAAK,CAAC,YAAY,CAAQ,GAAmB,EAAkB,IAAa,EAAkB,KAAc;QACxG,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,QAAQ,CAAC,IAAI,IAAI,IAAI,EAAE,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE,GAAG,EAAE,CAAC,CAAC;QAC/E,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,gBAAgB,CAAC,KAAK,EAAE,EAAE,KAAK,EAAE,KAAK,KAAK,GAAG,IAAI,KAAK,KAAK,MAAM,EAAE,CAAC,CAAC;QACxG,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,MAAM,EAAE,CAAC;IAC1C,CAAC;CACJ,CAAA;AA/EY,8DAAyB;AAQ5B;IADL,IAAA,YAAG,EAAC,6BAA6B,CAAC;IAE9B,WAAA,IAAA,cAAK,EAAC,WAAW,CAAC,CAAA;IAClB,WAAA,IAAA,cAAK,EAAC,WAAW,CAAC,CAAA;IAClB,WAAA,IAAA,cAAK,EAAC,OAAO,CAAC,CAAA;;;;2DAQlB;AAUK;IADL,IAAA,YAAG,EAAC,0BAA0B,CAAC;IAE3B,WAAA,IAAA,cAAK,EAAC,WAAW,CAAC,CAAA;IAClB,WAAA,IAAA,cAAK,EAAC,WAAW,CAAC,CAAA;IAClB,WAAA,IAAA,cAAK,EAAC,OAAO,CAAC,CAAA;;;;yDAQlB;AAOK;IADL,IAAA,YAAG,EAAC,0BAA0B,CAAC;IAE3B,WAAA,IAAA,cAAK,EAAC,WAAW,CAAC,CAAA;IAClB,WAAA,IAAA,cAAK,EAAC,OAAO,CAAC,CAAA;IACd,WAAA,IAAA,cAAK,EAAC,OAAO,CAAC,CAAA;;;;yDAalB;AAWK;IAFL,IAAA,YAAG,EAAC,+BAA+B,CAAC;IACpC,IAAA,YAAK,EAAC,iBAAU,CAAC,UAAU,CAAC;IACT,WAAA,IAAA,UAAG,GAAE,CAAA;IAAuB,WAAA,IAAA,cAAK,EAAC,OAAO,CAAC,CAAA;IAAiB,WAAA,IAAA,cAAK,EAAC,OAAO,CAAC,CAAA;;qCAA9D,qBAAc;;6DAI5C;oCA9EQ,yBAAyB;IADrC,IAAA,mBAAU,EAAC,KAAK,CAAC;qCAEwB,gDAAsB;GADnD,yBAAyB,CA+ErC"}
@@ -38,19 +38,39 @@ export interface RecommendedProduct {
38
38
  export declare class RecommendationsService {
39
39
  private connection;
40
40
  constructor(connection: TransactionalConnection);
41
+ /** Persisted high-water mark of the last aggregated window (event time). */
42
+ private static readonly WATERMARK_KEY;
43
+ private watermark;
44
+ private aggregating;
45
+ private store;
41
46
  /**
42
- * Rebuild the co-view aggregate from `visitor_event`. Idempotent
43
- * per (A, B, channelId) — repeated calls over the same window
44
- * accumulate, so schedule this as a nightly cron with a rolling
45
- * lookback (e.g. last 24h) rather than a full-history rescan.
47
+ * Rebuild the co-view aggregate from `visitor_event` for the window
48
+ * `[since, now)`. Idempotent for overlapping calls: the end of the last
49
+ * aggregated window is persisted, and a later call only counts events
50
+ * after it (`aggregate-now` after the 6-hourly cron therefore adds
51
+ * nothing instead of doubling every pair). `force: true` ignores the
52
+ * watermark (e.g. after truncating `product_co_view`).
46
53
  *
47
- * Returns the number of pair-triples updated. Uses a bounded
48
- * ordered-pair extractor (max 20 events per session) so a single
49
- * bot session can't skew the table.
54
+ * Pairs are accumulated in memory per (A, B, channel) and written as
55
+ * multi-row upserts in chunks of 500 — the previous version issued one
56
+ * round trip per ordered pair, so a 20-product session cost 380 statements.
57
+ *
58
+ * Bounded to 20 events per session so a single bot session cannot skew
59
+ * the table.
50
60
  */
51
- aggregateCoViews(sinceHours?: number): Promise<{
61
+ aggregateCoViews(sinceHours?: number, opts?: {
62
+ force?: boolean;
63
+ }): Promise<{
52
64
  pairs: number;
65
+ sessions: number;
66
+ since: string;
67
+ until: string;
68
+ skipped: boolean;
53
69
  }>;
70
+ private loadWatermark;
71
+ private saveWatermark;
72
+ /** Monthly housekeeping (worker): pairs not refreshed for `days` go in batches. */
73
+ pruneStalePairs(days?: number, batch?: number): Promise<number>;
54
74
  /**
55
75
  * Read side: "customers who viewed X also viewed Y" for one
56
76
  * product, ordered by co-view score, capped at `limit`.
@@ -1 +1 @@
1
- {"version":3,"file":"recommendations.service.d.ts","sourceRoot":"","sources":["../src/recommendations.service.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,uBAAuB,EAAE,MAAM,eAAe,CAAC;AAKxD;;;uDAGuD;AACvD,MAAM,WAAW,kBAAkB;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B;;;yDAGqD;IACrD,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,qBACa,sBAAsB;IACnB,OAAO,CAAC,UAAU;gBAAV,UAAU,EAAE,uBAAuB;IAEvD;;;;;;;;;OASG;IACG,gBAAgB,CAAC,UAAU,SAAK,GAAG,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;IA0DnE;;;OAGG;IACG,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,SAAI,EAAE,KAAK,SAAK,GAAG,OAAO,CAAC,kBAAkB,EAAE,CAAC;IAgB7F;;;;;OAKG;IACG,uBAAuB,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,SAAI,EAAE,KAAK,SAAK,GAAG,OAAO,CAAC,kBAAkB,EAAE,CAAC;IAmC1G;;;;OAIG;IACG,QAAQ,CAAC,SAAS,SAAI,EAAE,UAAU,SAAK,EAAE,KAAK,SAAK,GAAG,OAAO,CAAC,kBAAkB,EAAE,CAAC;IAwBzF;;;;;;;;OAQG;YACW,gBAAgB;IAuC9B,2DAA2D;YAC7C,eAAe;CAehC"}
1
+ {"version":3,"file":"recommendations.service.d.ts","sourceRoot":"","sources":["../src/recommendations.service.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,uBAAuB,EAAE,MAAM,eAAe,CAAC;AAMxD;;;uDAGuD;AACvD,MAAM,WAAW,kBAAkB;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B;;;yDAGqD;IACrD,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,qBACa,sBAAsB;IACnB,OAAO,CAAC,UAAU;gBAAV,UAAU,EAAE,uBAAuB;IAEvD,4EAA4E;IAC5E,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,aAAa,CAAwC;IAC7E,OAAO,CAAC,SAAS,CAAqB;IACtC,OAAO,CAAC,WAAW,CAAS;IAE5B,OAAO,CAAC,KAAK;IAIb;;;;;;;;;;;;;;OAcG;IACG,gBAAgB,CAAC,UAAU,SAAK,EAAE,IAAI,GAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAO,GAAG,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC;YA4EvJ,aAAa;YAYb,aAAa;IAM3B,mFAAmF;IAC7E,eAAe,CAAC,IAAI,SAAK,EAAE,KAAK,SAAO,GAAG,OAAO,CAAC,MAAM,CAAC;IAsB/D;;;OAGG;IACG,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,SAAI,EAAE,KAAK,SAAK,GAAG,OAAO,CAAC,kBAAkB,EAAE,CAAC;IAgB7F;;;;;OAKG;IACG,uBAAuB,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,SAAI,EAAE,KAAK,SAAK,GAAG,OAAO,CAAC,kBAAkB,EAAE,CAAC;IAsC1G;;;;OAIG;IACG,QAAQ,CAAC,SAAS,SAAI,EAAE,UAAU,SAAK,EAAE,KAAK,SAAK,GAAG,OAAO,CAAC,kBAAkB,EAAE,CAAC;IA4BzF;;;;;;;;OAQG;YACW,gBAAgB;IAuC9B,2DAA2D;YAC7C,eAAe;CAehC"}
@@ -8,11 +8,13 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
8
8
  var __metadata = (this && this.__metadata) || function (k, v) {
9
9
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
10
  };
11
+ var RecommendationsService_1;
11
12
  Object.defineProperty(exports, "__esModule", { value: true });
12
13
  exports.RecommendationsService = void 0;
13
14
  const common_1 = require("@nestjs/common");
14
15
  const core_1 = require("@vendure/core");
15
16
  const vendure_licence_sdk_1 = require("@huloglobal/vendure-licence-sdk");
17
+ const sql_fragments_1 = require("./sql-fragments");
16
18
  const loggerCtx = 'HuloRecommendationsService';
17
19
  /**
18
20
  * Product recommendations driven by observed co-viewing behaviour.
@@ -35,80 +37,155 @@ const loggerCtx = 'HuloRecommendationsService';
35
37
  * are still returned so storefronts that prefer their own hydration
36
38
  * path can ignore the extra fields.
37
39
  */
38
- let RecommendationsService = class RecommendationsService {
40
+ let RecommendationsService = RecommendationsService_1 = class RecommendationsService {
39
41
  constructor(connection) {
40
42
  this.connection = connection;
43
+ this.watermark = null;
44
+ this.aggregating = false;
45
+ }
46
+ store() {
47
+ return new vendure_licence_sdk_1.LicenceStore((sql, params) => (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(sql, params));
41
48
  }
42
49
  /**
43
- * Rebuild the co-view aggregate from `visitor_event`. Idempotent
44
- * per (A, B, channelId) — repeated calls over the same window
45
- * accumulate, so schedule this as a nightly cron with a rolling
46
- * lookback (e.g. last 24h) rather than a full-history rescan.
50
+ * Rebuild the co-view aggregate from `visitor_event` for the window
51
+ * `[since, now)`. Idempotent for overlapping calls: the end of the last
52
+ * aggregated window is persisted, and a later call only counts events
53
+ * after it (`aggregate-now` after the 6-hourly cron therefore adds
54
+ * nothing instead of doubling every pair). `force: true` ignores the
55
+ * watermark (e.g. after truncating `product_co_view`).
56
+ *
57
+ * Pairs are accumulated in memory per (A, B, channel) and written as
58
+ * multi-row upserts in chunks of 500 — the previous version issued one
59
+ * round trip per ordered pair, so a 20-product session cost 380 statements.
47
60
  *
48
- * Returns the number of pair-triples updated. Uses a bounded
49
- * ordered-pair extractor (max 20 events per session) so a single
50
- * bot session can't skew the table.
61
+ * Bounded to 20 events per session so a single bot session cannot skew
62
+ * the table.
51
63
  */
52
- async aggregateCoViews(sinceHours = 24) {
64
+ async aggregateCoViews(sinceHours = 24, opts = {}) {
53
65
  const conn = (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection);
54
- const since = new Date(Date.now() - sinceHours * 3600000);
55
- // Extract every session's product-view sequence within the
56
- // window. Bounded to 20 events per session to keep pathological
57
- // sessions from blowing up the table.
58
- const sessions = await conn.query(`SELECT
59
- sessionId,
60
- channelId,
61
- GROUP_CONCAT(
62
- SUBSTRING_INDEX(SUBSTRING_INDEX(meta, '"productId":', -1), ',', 1)
63
- ORDER BY createdAt
64
- SEPARATOR ','
65
- ) AS ids
66
- FROM visitor_event
67
- WHERE type = 'event'
68
- AND meta LIKE '%"eventType":"product_view"%'
69
- AND createdAt >= ?
70
- GROUP BY sessionId, channelId
71
- HAVING COUNT(*) BETWEEN 2 AND 20`, [since]);
72
- let pairs = 0;
73
- for (const s of sessions) {
74
- const rawIds = String(s.ids || '')
75
- .split(',')
76
- .map((x) => parseInt(x.replace(/[^0-9]/g, ''), 10))
77
- .filter((n) => Number.isFinite(n) && n > 0);
78
- if (rawIds.length < 2)
79
- continue;
80
- const uniq = Array.from(new Set(rawIds));
81
- if (uniq.length < 2)
82
- continue;
83
- for (let i = 0; i < uniq.length; i++) {
84
- for (let j = 0; j < uniq.length; j++) {
85
- if (i === j)
86
- continue;
87
- const a = uniq[i];
88
- const b = uniq[j];
89
- // Upsert-with-increment via ON DUPLICATE KEY UPDATE.
90
- await conn.query(`INSERT INTO product_co_view
91
- (productIdA, productIdB, channelId, viewsTogether, lastUpdated)
92
- VALUES (?, ?, ?, 1, NOW(3))
93
- ON DUPLICATE KEY UPDATE
94
- viewsTogether = viewsTogether + 1,
95
- lastUpdated = NOW(3)`, [a, b, s.channelId || 1], { conflictColumns: ['productIdA', 'productIdB', 'channelId'] });
96
- pairs += 1;
66
+ const until = new Date();
67
+ let since = new Date(until.getTime() - sinceHours * 3600000);
68
+ if (this.aggregating)
69
+ return { pairs: 0, sessions: 0, since: since.toISOString(), until: until.toISOString(), skipped: true };
70
+ this.aggregating = true;
71
+ try {
72
+ if (!opts.force) {
73
+ const mark = await this.loadWatermark();
74
+ if (mark && mark > since)
75
+ since = mark;
76
+ }
77
+ if (since >= until)
78
+ return { pairs: 0, sessions: 0, since: since.toISOString(), until: until.toISOString(), skipped: true };
79
+ const sessions = await conn.query(`SELECT
80
+ \`sessionId\`,
81
+ \`channelId\`,
82
+ GROUP_CONCAT(
83
+ ${sql_fragments_1.PRODUCT_ID_TOKEN_LOOSE}
84
+ ORDER BY \`createdAt\`
85
+ SEPARATOR ','
86
+ ) AS ids
87
+ FROM visitor_event
88
+ WHERE type = 'event'
89
+ AND meta LIKE '%"eventType":"product_view"%'
90
+ AND \`createdAt\` >= ?
91
+ AND \`createdAt\` < ?
92
+ GROUP BY \`sessionId\`, \`channelId\`
93
+ HAVING COUNT(*) BETWEEN 2 AND 20`, [since, until]);
94
+ // (a, b, channel) → co-view count for this window.
95
+ const counts = new Map();
96
+ for (const s of sessions) {
97
+ const rawIds = String(s.ids || '')
98
+ .split(',')
99
+ .map((x) => parseInt(x.replace(/[^0-9]/g, ''), 10))
100
+ .filter((n) => Number.isFinite(n) && n > 0);
101
+ const uniq = Array.from(new Set(rawIds));
102
+ if (uniq.length < 2)
103
+ continue;
104
+ const ch = Number(s.channelId) || 1;
105
+ for (const a of uniq) {
106
+ for (const b of uniq) {
107
+ if (a === b)
108
+ continue;
109
+ const key = `${a}|${b}|${ch}`;
110
+ const hit = counts.get(key);
111
+ if (hit)
112
+ hit.n += 1;
113
+ else
114
+ counts.set(key, { a, b, ch, n: 1 });
115
+ }
97
116
  }
98
117
  }
118
+ const rows = Array.from(counts.values());
119
+ const CHUNK = 500;
120
+ for (let i = 0; i < rows.length; i += CHUNK) {
121
+ const chunk = rows.slice(i, i + CHUNK);
122
+ const valuesSql = chunk.map(() => '(?, ?, ?, ?, NOW(3))').join(', ');
123
+ await conn.query(`INSERT INTO product_co_view
124
+ (\`productIdA\`, \`productIdB\`, \`channelId\`, \`viewsTogether\`, \`lastUpdated\`)
125
+ VALUES ${valuesSql}
126
+ ON DUPLICATE KEY UPDATE
127
+ \`viewsTogether\` = product_co_view.\`viewsTogether\` + VALUES(\`viewsTogether\`),
128
+ \`lastUpdated\` = NOW(3)`, chunk.flatMap(r => [r.a, r.b, r.ch, r.n]), { conflictColumns: sql_fragments_1.PRODUCT_CO_VIEW_CONFLICT });
129
+ }
130
+ await this.saveWatermark(until);
131
+ common_1.Logger.log(`Co-view aggregation: ${sessions.length} sessions, ${rows.length} pair rows (${since.toISOString()} → ${until.toISOString()})`, loggerCtx);
132
+ return { pairs: rows.length, sessions: sessions.length, since: since.toISOString(), until: until.toISOString(), skipped: false };
133
+ }
134
+ finally {
135
+ this.aggregating = false;
99
136
  }
100
- common_1.Logger.log(`Co-view aggregation: ${sessions.length} sessions, ${pairs} pair updates`, loggerCtx);
101
- return { pairs };
137
+ }
138
+ async loadWatermark() {
139
+ if (this.watermark)
140
+ return this.watermark;
141
+ try {
142
+ const store = this.store();
143
+ await store.ensureTable();
144
+ const raw = await store.load(RecommendationsService_1.WATERMARK_KEY);
145
+ const d = raw ? new Date(raw) : null;
146
+ this.watermark = d && Number.isFinite(d.getTime()) ? d : null;
147
+ }
148
+ catch {
149
+ this.watermark = null;
150
+ }
151
+ return this.watermark;
152
+ }
153
+ async saveWatermark(until) {
154
+ this.watermark = until;
155
+ try {
156
+ await this.store().save(RecommendationsService_1.WATERMARK_KEY, until.toISOString());
157
+ }
158
+ catch (e) {
159
+ common_1.Logger.warn(`co-view watermark not persisted: ${e === null || e === void 0 ? void 0 : e.message}`, loggerCtx);
160
+ }
161
+ }
162
+ /** Monthly housekeeping (worker): pairs not refreshed for `days` go in batches. */
163
+ async pruneStalePairs(days = 90, batch = 5000) {
164
+ const conn = (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection);
165
+ let deleted = 0;
166
+ for (let round = 0; round < 400; round++) {
167
+ const rows = await conn.query(`SELECT \`productIdA\`, \`productIdB\`, \`channelId\` FROM product_co_view
168
+ WHERE \`lastUpdated\` < DATE_SUB(NOW(), INTERVAL ? DAY)
169
+ ORDER BY \`productIdA\`, \`productIdB\`, \`channelId\` LIMIT ?`, [days, batch]);
170
+ if (!rows.length)
171
+ break;
172
+ const tuples = rows.map(() => '(?, ?, ?)').join(', ');
173
+ await conn.query(`DELETE FROM product_co_view WHERE (\`productIdA\`, \`productIdB\`, \`channelId\`) IN (${tuples})`, rows.flatMap((r) => [Number(r.productIdA), Number(r.productIdB), Number(r.channelId)]));
174
+ deleted += rows.length;
175
+ if (rows.length < batch)
176
+ break;
177
+ }
178
+ return deleted;
102
179
  }
103
180
  /**
104
181
  * Read side: "customers who viewed X also viewed Y" for one
105
182
  * product, ordered by co-view score, capped at `limit`.
106
183
  */
107
184
  async alsoViewed(productId, channelId = 1, limit = 10) {
108
- const rows = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT productIdB AS productId, viewsTogether AS score
185
+ const rows = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT \`productIdB\` AS \`productId\`, \`viewsTogether\` AS score
109
186
  FROM product_co_view
110
- WHERE productIdA = ? AND channelId = ?
111
- ORDER BY viewsTogether DESC, lastUpdated DESC
187
+ WHERE \`productIdA\` = ? AND \`channelId\` = ?
188
+ ORDER BY \`viewsTogether\` DESC, \`lastUpdated\` DESC
112
189
  LIMIT ?`, [productId, channelId, Math.min(Math.max(1, limit), 50)]);
113
190
  return this.enrichWithNames(rows.map(r => ({
114
191
  productId: Number(r.productId),
@@ -124,25 +201,28 @@ let RecommendationsService = class RecommendationsService {
124
201
  */
125
202
  async personalRecommendations(visitorId, channelId = 1, limit = 10) {
126
203
  const conn = (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection);
127
- const seeds = await conn.query(`SELECT DISTINCT
128
- CAST(SUBSTRING_INDEX(SUBSTRING_INDEX(meta, '"productId":', -1), ',', 1) AS UNSIGNED) AS productId
204
+ // No DISTINCT + ORDER BY on an unselected column (Postgres rejects it):
205
+ // group by the extracted id and order by the latest view instead.
206
+ const seeds = await conn.query(`SELECT CAST(${sql_fragments_1.PRODUCT_ID_TOKEN} AS UNSIGNED) AS \`productId\`, MAX(\`createdAt\`) AS \`lastSeen\`
129
207
  FROM visitor_event
130
- WHERE visitorId = ?
208
+ WHERE \`visitorId\` = ?
131
209
  AND type = 'event'
132
210
  AND meta LIKE '%"eventType":"product_view"%'
133
- AND createdAt >= (NOW() - INTERVAL 30 DAY)
134
- ORDER BY createdAt DESC
211
+ AND \`createdAt\` >= DATE_SUB(NOW(), INTERVAL 30 DAY)
212
+ AND ${(0, sql_fragments_1.digitsOnly)(conn.dialect, sql_fragments_1.PRODUCT_ID_TOKEN)}
213
+ GROUP BY CAST(${sql_fragments_1.PRODUCT_ID_TOKEN} AS UNSIGNED)
214
+ ORDER BY \`lastSeen\` DESC
135
215
  LIMIT 10`, [visitorId]);
136
216
  const seedIds = seeds.map(s => Number(s.productId)).filter(n => n > 0);
137
217
  if (!seedIds.length)
138
218
  return [];
139
219
  const placeholders = seedIds.map(() => '?').join(',');
140
- const rows = await conn.query(`SELECT productIdB AS productId, SUM(viewsTogether) AS score
220
+ const rows = await conn.query(`SELECT \`productIdB\` AS \`productId\`, SUM(\`viewsTogether\`) AS score
141
221
  FROM product_co_view
142
- WHERE productIdA IN (${placeholders})
143
- AND channelId = ?
144
- AND productIdB NOT IN (${placeholders})
145
- GROUP BY productIdB
222
+ WHERE \`productIdA\` IN (${placeholders})
223
+ AND \`channelId\` = ?
224
+ AND \`productIdB\` NOT IN (${placeholders})
225
+ GROUP BY \`productIdB\`
146
226
  ORDER BY score DESC
147
227
  LIMIT ?`, [...seedIds, channelId, ...seedIds, Math.min(Math.max(1, limit), 50)]);
148
228
  return this.enrichWithNames(rows.map(r => ({
@@ -157,17 +237,21 @@ let RecommendationsService = class RecommendationsService {
157
237
  * reflects real interest, not just search-console clicks.
158
238
  */
159
239
  async trending(channelId = 1, sinceHours = 24, limit = 10) {
240
+ const conn = (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection);
160
241
  const since = new Date(Date.now() - sinceHours * 3600000);
161
- const rows = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT
162
- CAST(SUBSTRING_INDEX(SUBSTRING_INDEX(meta, '"productId":', -1), ',', 1) AS UNSIGNED) AS productId,
242
+ // The digits guard keeps CAST from raising on Postgres; GROUP BY / HAVING repeat
243
+ // the expression because Postgres does not resolve output aliases in HAVING.
244
+ const rows = await conn.query(`SELECT
245
+ CAST(${sql_fragments_1.PRODUCT_ID_TOKEN} AS UNSIGNED) AS \`productId\`,
163
246
  COUNT(*) AS views
164
247
  FROM visitor_event
165
248
  WHERE type = 'event'
166
249
  AND meta LIKE '%"eventType":"product_view"%'
167
- AND channelId = ?
168
- AND createdAt >= ?
169
- GROUP BY productId
170
- HAVING productId > 0
250
+ AND \`channelId\` = ?
251
+ AND \`createdAt\` >= ?
252
+ AND ${(0, sql_fragments_1.digitsOnly)(conn.dialect, sql_fragments_1.PRODUCT_ID_TOKEN)}
253
+ GROUP BY CAST(${sql_fragments_1.PRODUCT_ID_TOKEN} AS UNSIGNED)
254
+ HAVING CAST(${sql_fragments_1.PRODUCT_ID_TOKEN} AS UNSIGNED) > 0
171
255
  ORDER BY views DESC
172
256
  LIMIT ?`, [channelId, since, Math.min(Math.max(1, limit), 50)]);
173
257
  return this.enrichWithNames(rows.map(r => ({
@@ -197,11 +281,11 @@ let RecommendationsService = class RecommendationsService {
197
281
  // languageCode = 'en' DESC so English wins the tie for
198
282
  // multi-locale stores; single-locale installs will
199
283
  // naturally land on their only translation.
200
- const rows = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT pt.baseId AS productId, pt.name, pt.slug
284
+ const rows = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT pt.\`baseId\` AS \`productId\`, pt.name, pt.slug
201
285
  FROM product_translation pt
202
- JOIN product p ON p.id = pt.baseId AND p.deletedAt IS NULL
203
- WHERE pt.baseId IN (${placeholders})
204
- ORDER BY pt.baseId, pt.languageCode = 'en' DESC`, ids);
286
+ JOIN product p ON p.id = pt.\`baseId\` AND p.\`deletedAt\` IS NULL
287
+ WHERE pt.\`baseId\` IN (${placeholders})
288
+ ORDER BY pt.\`baseId\`, pt.\`languageCode\` = 'en' DESC`, ids);
205
289
  for (const r of rows) {
206
290
  const id = Number(r.productId);
207
291
  // First-write wins — the ORDER BY puts English first
@@ -235,7 +319,9 @@ let RecommendationsService = class RecommendationsService {
235
319
  }
236
320
  };
237
321
  exports.RecommendationsService = RecommendationsService;
238
- exports.RecommendationsService = RecommendationsService = __decorate([
322
+ /** Persisted high-water mark of the last aggregated window (event time). */
323
+ RecommendationsService.WATERMARK_KEY = 'visitor-analytics-coview-watermark';
324
+ exports.RecommendationsService = RecommendationsService = RecommendationsService_1 = __decorate([
239
325
  (0, common_1.Injectable)(),
240
326
  __metadata("design:paramtypes", [core_1.TransactionalConnection])
241
327
  ], RecommendationsService);