@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.
- package/CHANGELOG.md +58 -0
- package/README.md +20 -8
- package/dist/abandoned-cart.controller.d.ts.map +1 -1
- package/dist/abandoned-cart.controller.js +55 -38
- package/dist/abandoned-cart.controller.js.map +1 -1
- package/dist/abandoned-cart.scanner.d.ts +4 -0
- package/dist/abandoned-cart.scanner.d.ts.map +1 -1
- package/dist/abandoned-cart.scanner.js +28 -0
- package/dist/abandoned-cart.scanner.js.map +1 -1
- package/dist/abandoned-cart.service.d.ts +9 -0
- package/dist/abandoned-cart.service.d.ts.map +1 -1
- package/dist/abandoned-cart.service.js +125 -84
- package/dist/abandoned-cart.service.js.map +1 -1
- package/dist/admin-api.js +22 -22
- package/dist/admin-api.js.map +1 -1
- package/dist/ingest-hardening.test.js +6 -6
- package/dist/ingest-hardening.test.js.map +1 -1
- package/dist/ingest-utils.d.ts +6 -0
- package/dist/ingest-utils.d.ts.map +1 -0
- package/dist/ingest-utils.js +34 -0
- package/dist/ingest-utils.js.map +1 -0
- package/dist/journey-buffs.service.js +21 -21
- package/dist/recommendations.controller.d.ts +10 -4
- package/dist/recommendations.controller.d.ts.map +1 -1
- package/dist/recommendations.controller.js +9 -6
- package/dist/recommendations.controller.js.map +1 -1
- package/dist/recommendations.service.d.ts +28 -8
- package/dist/recommendations.service.d.ts.map +1 -1
- package/dist/recommendations.service.js +164 -78
- package/dist/recommendations.service.js.map +1 -1
- package/dist/search-analytics.service.d.ts.map +1 -1
- package/dist/search-analytics.service.js +22 -18
- package/dist/search-analytics.service.js.map +1 -1
- package/dist/sql-fragments.d.ts +25 -0
- package/dist/sql-fragments.d.ts.map +1 -0
- package/dist/sql-fragments.js +31 -0
- package/dist/sql-fragments.js.map +1 -0
- package/dist/visitor-tracking.controller.d.ts +8 -1
- package/dist/visitor-tracking.controller.d.ts.map +1 -1
- package/dist/visitor-tracking.controller.js +219 -134
- package/dist/visitor-tracking.controller.js.map +1 -1
- package/dist/visitor-tracking.service.d.ts +0 -4
- package/dist/visitor-tracking.service.d.ts.map +1 -1
- package/dist/visitor-tracking.service.js +3 -32
- package/dist/visitor-tracking.service.js.map +1 -1
- 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,
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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:
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
|
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:
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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
|
|
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;
|
|
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
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
-
*
|
|
49
|
-
*
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
|
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
|
-
|
|
128
|
-
|
|
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()
|
|
134
|
-
|
|
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
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
|
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
|
|
203
|
-
WHERE pt
|
|
204
|
-
ORDER BY pt
|
|
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
|
-
|
|
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);
|