@huloglobal/vendure-plugin-visitor-analytics 0.7.0 → 0.8.0

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 (54) hide show
  1. package/CHANGELOG.md +83 -15
  2. package/dist/abandoned-cart.controller.d.ts +78 -0
  3. package/dist/abandoned-cart.controller.d.ts.map +1 -0
  4. package/dist/abandoned-cart.controller.js +251 -0
  5. package/dist/abandoned-cart.controller.js.map +1 -0
  6. package/dist/abandoned-cart.entity.d.ts +61 -0
  7. package/dist/abandoned-cart.entity.d.ts.map +1 -0
  8. package/dist/abandoned-cart.entity.js +143 -0
  9. package/dist/abandoned-cart.entity.js.map +1 -0
  10. package/dist/abandoned-cart.scanner.d.ts +34 -0
  11. package/dist/abandoned-cart.scanner.d.ts.map +1 -0
  12. package/dist/abandoned-cart.scanner.js +85 -0
  13. package/dist/abandoned-cart.scanner.js.map +1 -0
  14. package/dist/abandoned-cart.service.d.ts +92 -0
  15. package/dist/abandoned-cart.service.d.ts.map +1 -0
  16. package/dist/abandoned-cart.service.js +276 -0
  17. package/dist/abandoned-cart.service.js.map +1 -0
  18. package/dist/index.d.ts +6 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +13 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/journey-buffs.controller.d.ts +43 -0
  23. package/dist/journey-buffs.controller.d.ts.map +1 -0
  24. package/dist/journey-buffs.controller.js +79 -0
  25. package/dist/journey-buffs.controller.js.map +1 -0
  26. package/dist/journey-buffs.service.d.ts +53 -0
  27. package/dist/journey-buffs.service.d.ts.map +1 -0
  28. package/dist/journey-buffs.service.js +141 -0
  29. package/dist/journey-buffs.service.js.map +1 -0
  30. package/dist/plugin.d.ts +5 -0
  31. package/dist/plugin.d.ts.map +1 -1
  32. package/dist/plugin.js +28 -3
  33. package/dist/plugin.js.map +1 -1
  34. package/dist/product-co-view.entity.d.ts +19 -0
  35. package/dist/product-co-view.entity.d.ts.map +1 -0
  36. package/dist/product-co-view.entity.js +52 -0
  37. package/dist/product-co-view.entity.js.map +1 -0
  38. package/dist/recommendations.controller.d.ts +72 -0
  39. package/dist/recommendations.controller.d.ts.map +1 -0
  40. package/dist/recommendations.controller.js +123 -0
  41. package/dist/recommendations.controller.js.map +1 -0
  42. package/dist/recommendations.service.d.ts +62 -0
  43. package/dist/recommendations.service.d.ts.map +1 -0
  44. package/dist/recommendations.service.js +167 -0
  45. package/dist/recommendations.service.js.map +1 -0
  46. package/dist/search-analytics.controller.d.ts +36 -0
  47. package/dist/search-analytics.controller.d.ts.map +1 -0
  48. package/dist/search-analytics.controller.js +86 -0
  49. package/dist/search-analytics.controller.js.map +1 -0
  50. package/dist/search-analytics.service.d.ts +36 -0
  51. package/dist/search-analytics.service.d.ts.map +1 -0
  52. package/dist/search-analytics.service.js +105 -0
  53. package/dist/search-analytics.service.js.map +1 -0
  54. package/package.json +1 -1
@@ -0,0 +1,143 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __metadata = (this && this.__metadata) || function (k, v) {
9
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.AbandonedCart = void 0;
13
+ const typeorm_1 = require("typeorm");
14
+ /**
15
+ * One row per abandoned cart, keyed on the visitor session that produced
16
+ * it. Rebuilt fresh from `cart_snapshot` events by the periodic scanner,
17
+ * so a session can be updated in-place until it either converts (order
18
+ * placed) or expires (recovery window elapses without any action).
19
+ */
20
+ let AbandonedCart = class AbandonedCart {
21
+ };
22
+ exports.AbandonedCart = AbandonedCart;
23
+ __decorate([
24
+ (0, typeorm_1.PrimaryGeneratedColumn)(),
25
+ __metadata("design:type", Number)
26
+ ], AbandonedCart.prototype, "id", void 0);
27
+ __decorate([
28
+ (0, typeorm_1.Column)({ length: 64 }),
29
+ __metadata("design:type", String)
30
+ ], AbandonedCart.prototype, "visitorId", void 0);
31
+ __decorate([
32
+ (0, typeorm_1.Column)({ length: 64 }),
33
+ __metadata("design:type", String)
34
+ ], AbandonedCart.prototype, "sessionId", void 0);
35
+ __decorate([
36
+ (0, typeorm_1.Column)({ type: 'int', nullable: true }),
37
+ __metadata("design:type", Object)
38
+ ], AbandonedCart.prototype, "customerId", void 0);
39
+ __decorate([
40
+ (0, typeorm_1.Column)({ type: 'int', default: 1 }),
41
+ __metadata("design:type", Number)
42
+ ], AbandonedCart.prototype, "channelId", void 0);
43
+ __decorate([
44
+ (0, typeorm_1.Column)({ length: 3 }),
45
+ __metadata("design:type", String)
46
+ ], AbandonedCart.prototype, "currency", void 0);
47
+ __decorate([
48
+ (0, typeorm_1.Column)({ type: 'int' }),
49
+ __metadata("design:type", Number)
50
+ ], AbandonedCart.prototype, "totalMinor", void 0);
51
+ __decorate([
52
+ (0, typeorm_1.Column)({ type: 'int' }),
53
+ __metadata("design:type", Number)
54
+ ], AbandonedCart.prototype, "itemCount", void 0);
55
+ __decorate([
56
+ (0, typeorm_1.Column)({ type: 'text' }),
57
+ __metadata("design:type", String)
58
+ ], AbandonedCart.prototype, "itemsJson", void 0);
59
+ __decorate([
60
+ (0, typeorm_1.Column)({ type: 'varchar', length: 255, nullable: true }),
61
+ __metadata("design:type", Object)
62
+ ], AbandonedCart.prototype, "email", void 0);
63
+ __decorate([
64
+ (0, typeorm_1.Column)({ type: 'varchar', length: 64, nullable: true }),
65
+ __metadata("design:type", Object)
66
+ ], AbandonedCart.prototype, "emailHash", void 0);
67
+ __decorate([
68
+ (0, typeorm_1.Column)({ type: 'datetime', precision: 3 }),
69
+ __metadata("design:type", Date)
70
+ ], AbandonedCart.prototype, "firstSnapshotAt", void 0);
71
+ __decorate([
72
+ (0, typeorm_1.Column)({ type: 'datetime', precision: 3 }),
73
+ __metadata("design:type", Date)
74
+ ], AbandonedCart.prototype, "lastSnapshotAt", void 0);
75
+ __decorate([
76
+ (0, typeorm_1.Column)({ type: 'datetime', precision: 3 }),
77
+ __metadata("design:type", Date)
78
+ ], AbandonedCart.prototype, "abandonedAt", void 0);
79
+ __decorate([
80
+ (0, typeorm_1.Column)({ type: 'datetime', precision: 3, nullable: true }),
81
+ __metadata("design:type", Object)
82
+ ], AbandonedCart.prototype, "recoveredAt", void 0);
83
+ __decorate([
84
+ (0, typeorm_1.Column)({ type: 'varchar', length: 128, nullable: true, unique: true }),
85
+ __metadata("design:type", Object)
86
+ ], AbandonedCart.prototype, "recoveryToken", void 0);
87
+ __decorate([
88
+ (0, typeorm_1.Column)({ type: 'datetime', precision: 3, nullable: true }),
89
+ __metadata("design:type", Object)
90
+ ], AbandonedCart.prototype, "recoveryTokenExpiresAt", void 0);
91
+ __decorate([
92
+ (0, typeorm_1.Column)({ type: 'varchar', length: 32, default: 'abandoned' }),
93
+ __metadata("design:type", String)
94
+ ], AbandonedCart.prototype, "status", void 0);
95
+ __decorate([
96
+ (0, typeorm_1.Column)({ type: 'int', nullable: true }),
97
+ __metadata("design:type", Object)
98
+ ], AbandonedCart.prototype, "recoveryOrderId", void 0);
99
+ __decorate([
100
+ (0, typeorm_1.Column)({ type: 'varchar', length: 2048, nullable: true }),
101
+ __metadata("design:type", Object)
102
+ ], AbandonedCart.prototype, "lastKnownUrl", void 0);
103
+ __decorate([
104
+ (0, typeorm_1.Column)({ type: 'varchar', length: 2048, nullable: true }),
105
+ __metadata("design:type", Object)
106
+ ], AbandonedCart.prototype, "lastKnownReferrer", void 0);
107
+ __decorate([
108
+ (0, typeorm_1.Column)({ type: 'varchar', length: 100, nullable: true }),
109
+ __metadata("design:type", Object)
110
+ ], AbandonedCart.prototype, "utmSource", void 0);
111
+ __decorate([
112
+ (0, typeorm_1.Column)({ type: 'varchar', length: 100, nullable: true }),
113
+ __metadata("design:type", Object)
114
+ ], AbandonedCart.prototype, "utmMedium", void 0);
115
+ __decorate([
116
+ (0, typeorm_1.Column)({ type: 'varchar', length: 200, nullable: true }),
117
+ __metadata("design:type", Object)
118
+ ], AbandonedCart.prototype, "utmCampaign", void 0);
119
+ __decorate([
120
+ (0, typeorm_1.Column)({ type: 'varchar', length: 16, nullable: true }),
121
+ __metadata("design:type", Object)
122
+ ], AbandonedCart.prototype, "countryCode", void 0);
123
+ __decorate([
124
+ (0, typeorm_1.Column)({ type: 'boolean', default: false }),
125
+ __metadata("design:type", Boolean)
126
+ ], AbandonedCart.prototype, "notificationSent", void 0);
127
+ __decorate([
128
+ (0, typeorm_1.CreateDateColumn)({ precision: 3 }),
129
+ __metadata("design:type", Date)
130
+ ], AbandonedCart.prototype, "createdAt", void 0);
131
+ __decorate([
132
+ (0, typeorm_1.UpdateDateColumn)({ precision: 3 }),
133
+ __metadata("design:type", Date)
134
+ ], AbandonedCart.prototype, "updatedAt", void 0);
135
+ exports.AbandonedCart = AbandonedCart = __decorate([
136
+ (0, typeorm_1.Entity)('abandoned_cart'),
137
+ (0, typeorm_1.Index)('abandoned_cart_session_uniq', ['sessionId'], { unique: true }),
138
+ (0, typeorm_1.Index)('abandoned_cart_visitor_idx', ['visitorId', 'abandonedAt']),
139
+ (0, typeorm_1.Index)('abandoned_cart_status_idx', ['status', 'abandonedAt']),
140
+ (0, typeorm_1.Index)('abandoned_cart_email_idx', ['emailHash']),
141
+ (0, typeorm_1.Index)('abandoned_cart_customer_idx', ['customerId'])
142
+ ], AbandonedCart);
143
+ //# sourceMappingURL=abandoned-cart.entity.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"abandoned-cart.entity.js","sourceRoot":"","sources":["../src/abandoned-cart.entity.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,34 @@
1
+ import { OnApplicationBootstrap, OnApplicationShutdown } from '@nestjs/common';
2
+ import { ProcessContext } from '@vendure/core';
3
+ import { AbandonedCartService } from './abandoned-cart.service';
4
+ import { RecommendationsService } from './recommendations.service';
5
+ /**
6
+ * Runs the two periodic sweeps this plugin depends on:
7
+ *
8
+ * 1. Abandoned-cart detection — every 5 minutes.
9
+ * Any session with `cart_snapshot` events but no
10
+ * `checkout_completed` in the abandonment window gets promoted.
11
+ *
12
+ * 2. Product co-view aggregation — every 6 hours.
13
+ * Walks recent `product_view` events and accumulates the
14
+ * pair counter used by the recommendations endpoints.
15
+ *
16
+ * Runs on the *worker* process only, not the server, so a horizontally
17
+ * scaled deployment doesn't run the sweeps N times per interval.
18
+ * Falls through cleanly on the server (bootstrap is a no-op).
19
+ *
20
+ * Uses `setInterval` rather than `@nestjs/schedule` — that package
21
+ * is only present on installs that opt in to it. This keeps the
22
+ * plugin's runtime footprint minimal.
23
+ */
24
+ export declare class AbandonedCartScanner implements OnApplicationBootstrap, OnApplicationShutdown {
25
+ private readonly cartSvc;
26
+ private readonly recsSvc;
27
+ private readonly processContext;
28
+ private cartTimer;
29
+ private recsTimer;
30
+ constructor(cartSvc: AbandonedCartService, recsSvc: RecommendationsService, processContext: ProcessContext);
31
+ onApplicationBootstrap(): void;
32
+ onApplicationShutdown(): void;
33
+ }
34
+ //# sourceMappingURL=abandoned-cart.scanner.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"abandoned-cart.scanner.d.ts","sourceRoot":"","sources":["../src/abandoned-cart.scanner.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AACnG,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,OAAO,EAAE,oBAAoB,EAAE,MAAM,0BAA0B,CAAC;AAChE,OAAO,EAAE,sBAAsB,EAAE,MAAM,2BAA2B,CAAC;AAInE;;;;;;;;;;;;;;;;;;GAkBG;AACH,qBACa,oBAAqB,YAAW,sBAAsB,EAAE,qBAAqB;IAKlF,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,cAAc;IANnC,OAAO,CAAC,SAAS,CAA+B;IAChD,OAAO,CAAC,SAAS,CAA+B;gBAG3B,OAAO,EAAE,oBAAoB,EAC7B,OAAO,EAAE,sBAAsB,EAC/B,cAAc,EAAE,cAAc;IAGnD,sBAAsB;IAyBtB,qBAAqB;CAIxB"}
@@ -0,0 +1,85 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __metadata = (this && this.__metadata) || function (k, v) {
9
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.AbandonedCartScanner = void 0;
13
+ const common_1 = require("@nestjs/common");
14
+ const core_1 = require("@vendure/core");
15
+ const abandoned_cart_service_1 = require("./abandoned-cart.service");
16
+ const recommendations_service_1 = require("./recommendations.service");
17
+ const loggerCtx = 'HuloAnalyticsScanner';
18
+ /**
19
+ * Runs the two periodic sweeps this plugin depends on:
20
+ *
21
+ * 1. Abandoned-cart detection — every 5 minutes.
22
+ * Any session with `cart_snapshot` events but no
23
+ * `checkout_completed` in the abandonment window gets promoted.
24
+ *
25
+ * 2. Product co-view aggregation — every 6 hours.
26
+ * Walks recent `product_view` events and accumulates the
27
+ * pair counter used by the recommendations endpoints.
28
+ *
29
+ * Runs on the *worker* process only, not the server, so a horizontally
30
+ * scaled deployment doesn't run the sweeps N times per interval.
31
+ * Falls through cleanly on the server (bootstrap is a no-op).
32
+ *
33
+ * Uses `setInterval` rather than `@nestjs/schedule` — that package
34
+ * is only present on installs that opt in to it. This keeps the
35
+ * plugin's runtime footprint minimal.
36
+ */
37
+ let AbandonedCartScanner = class AbandonedCartScanner {
38
+ constructor(cartSvc, recsSvc, processContext) {
39
+ this.cartSvc = cartSvc;
40
+ this.recsSvc = recsSvc;
41
+ this.processContext = processContext;
42
+ this.cartTimer = null;
43
+ this.recsTimer = null;
44
+ }
45
+ onApplicationBootstrap() {
46
+ // Only the worker process runs the sweeps. Guard here rather
47
+ // than throwing — a mono-process dev setup still works.
48
+ if (!this.processContext.isWorker)
49
+ return;
50
+ this.cartTimer = setInterval(() => {
51
+ this.cartSvc.scan().catch((e) => {
52
+ common_1.Logger.error(`Abandoned-cart scan failed: ${e === null || e === void 0 ? void 0 : e.message}`, loggerCtx);
53
+ });
54
+ }, 5 * 60000);
55
+ // Fire once at boot so a fresh install doesn't wait 5 min for
56
+ // its first sweep.
57
+ this.cartSvc.scan().catch((e) => {
58
+ common_1.Logger.error(`Initial abandoned-cart scan failed: ${e === null || e === void 0 ? void 0 : e.message}`, loggerCtx);
59
+ });
60
+ this.recsTimer = setInterval(() => {
61
+ this.recsSvc.aggregateCoViews(6).catch((e) => {
62
+ common_1.Logger.error(`Co-view aggregation failed: ${e === null || e === void 0 ? void 0 : e.message}`, loggerCtx);
63
+ });
64
+ }, 6 * 60 * 60000);
65
+ common_1.Logger.log('Abandoned-cart + recommendations scanners started (worker)', loggerCtx);
66
+ }
67
+ onApplicationShutdown() {
68
+ if (this.cartTimer) {
69
+ clearInterval(this.cartTimer);
70
+ this.cartTimer = null;
71
+ }
72
+ if (this.recsTimer) {
73
+ clearInterval(this.recsTimer);
74
+ this.recsTimer = null;
75
+ }
76
+ }
77
+ };
78
+ exports.AbandonedCartScanner = AbandonedCartScanner;
79
+ exports.AbandonedCartScanner = AbandonedCartScanner = __decorate([
80
+ (0, common_1.Injectable)(),
81
+ __metadata("design:paramtypes", [abandoned_cart_service_1.AbandonedCartService,
82
+ recommendations_service_1.RecommendationsService,
83
+ core_1.ProcessContext])
84
+ ], AbandonedCartScanner);
85
+ //# sourceMappingURL=abandoned-cart.scanner.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"abandoned-cart.scanner.js","sourceRoot":"","sources":["../src/abandoned-cart.scanner.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAAmG;AACnG,wCAA+C;AAC/C,qEAAgE;AAChE,uEAAmE;AAEnE,MAAM,SAAS,GAAG,sBAAsB,CAAC;AAEzC;;;;;;;;;;;;;;;;;;GAkBG;AAEI,IAAM,oBAAoB,GAA1B,MAAM,oBAAoB;IAI7B,YACqB,OAA6B,EAC7B,OAA+B,EAC/B,cAA8B;QAF9B,YAAO,GAAP,OAAO,CAAsB;QAC7B,YAAO,GAAP,OAAO,CAAwB;QAC/B,mBAAc,GAAd,cAAc,CAAgB;QAN3C,cAAS,GAA0B,IAAI,CAAC;QACxC,cAAS,GAA0B,IAAI,CAAC;IAM7C,CAAC;IAEJ,sBAAsB;QAClB,6DAA6D;QAC7D,wDAAwD;QACxD,IAAI,CAAC,IAAI,CAAC,cAAc,CAAC,QAAQ;YAAE,OAAO;QAE1C,IAAI,CAAC,SAAS,GAAG,WAAW,CAAC,GAAG,EAAE;YAC9B,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,CAAM,EAAE,EAAE;gBACjC,eAAM,CAAC,KAAK,CAAC,+BAA+B,CAAC,aAAD,CAAC,uBAAD,CAAC,CAAE,OAAO,EAAE,EAAE,SAAS,CAAC,CAAC;YACzE,CAAC,CAAC,CAAC;QACP,CAAC,EAAE,CAAC,GAAG,KAAM,CAAC,CAAC;QACf,8DAA8D;QAC9D,mBAAmB;QACnB,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,CAAM,EAAE,EAAE;YACjC,eAAM,CAAC,KAAK,CAAC,uCAAuC,CAAC,aAAD,CAAC,uBAAD,CAAC,CAAE,OAAO,EAAE,EAAE,SAAS,CAAC,CAAC;QACjF,CAAC,CAAC,CAAC;QAEH,IAAI,CAAC,SAAS,GAAG,WAAW,CAAC,GAAG,EAAE;YAC9B,IAAI,CAAC,OAAO,CAAC,gBAAgB,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAM,EAAE,EAAE;gBAC9C,eAAM,CAAC,KAAK,CAAC,+BAA+B,CAAC,aAAD,CAAC,uBAAD,CAAC,CAAE,OAAO,EAAE,EAAE,SAAS,CAAC,CAAC;YACzE,CAAC,CAAC,CAAC;QACP,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,KAAM,CAAC,CAAC;QAEpB,eAAM,CAAC,GAAG,CAAC,4DAA4D,EAAE,SAAS,CAAC,CAAC;IACxF,CAAC;IAED,qBAAqB;QACjB,IAAI,IAAI,CAAC,SAAS,EAAE,CAAC;YAAC,aAAa,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;YAAC,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC;QAAC,CAAC;QAC7E,IAAI,IAAI,CAAC,SAAS,EAAE,CAAC;YAAC,aAAa,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;YAAC,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC;QAAC,CAAC;IACjF,CAAC;CACJ,CAAA;AAvCY,oDAAoB;+BAApB,oBAAoB;IADhC,IAAA,mBAAU,GAAE;qCAMqB,6CAAoB;QACpB,gDAAsB;QACf,qBAAc;GAP1C,oBAAoB,CAuChC"}
@@ -0,0 +1,92 @@
1
+ import { TransactionalConnection } from '@vendure/core';
2
+ import { AbandonedCartStatus } from './abandoned-cart.entity';
3
+ /** Payload of a `cart_snapshot` custom event as posted by the storefront. */
4
+ export interface CartSnapshotMeta {
5
+ currency?: string;
6
+ totalMinor?: number;
7
+ itemCount?: number;
8
+ items?: Array<{
9
+ variantId?: number | string;
10
+ productId?: number | string;
11
+ name?: string;
12
+ qty?: number;
13
+ unitPriceMinor?: number;
14
+ sku?: string;
15
+ }>;
16
+ /** Optional — email captured at checkout step 1. */
17
+ email?: string;
18
+ countryCode?: string;
19
+ }
20
+ /**
21
+ * Config knobs. All optional; defaults tuned for the common case of a
22
+ * B2C storefront with a ~15 min average checkout time. Set via
23
+ * VisitorAnalyticsPluginOptions.abandonment.
24
+ */
25
+ export interface AbandonmentOptions {
26
+ /** Minutes since the last `cart_snapshot` before we mark a session
27
+ * as abandoned. Default 30 — long enough that a paused checkout
28
+ * isn't premature, short enough that recovery emails still feel
29
+ * timely. */
30
+ windowMinutes?: number;
31
+ /** Currency-minor threshold below which we don't emit a Slack
32
+ * notification. Prevents 3-figure inbox noise from every £5 cart
33
+ * drop-off. Default 5000 (£50 / $50). */
34
+ slackMinValueMinor?: number;
35
+ /** Slack webhook URL. When unset, notifications are logged only. */
36
+ slackWebhookUrl?: string;
37
+ /** Recovery-link HMAC secret. When unset, recovery-link generation
38
+ * returns null (feature disabled). Set this to something long and
39
+ * random. */
40
+ recoveryLinkSecret?: string;
41
+ /** Recovery-link TTL in hours. Default 72. */
42
+ recoveryLinkTtlHours?: number;
43
+ /** Storefront base URL — where the recovery link lands. Something
44
+ * like `https://shop.example.com`. Default: publicBaseUrl. */
45
+ storefrontBaseUrl?: string;
46
+ }
47
+ export declare class AbandonedCartService {
48
+ private connection;
49
+ constructor(connection: TransactionalConnection);
50
+ private opts;
51
+ /**
52
+ * Full detection pass. Runs periodically (cron). Finds every session
53
+ * that:
54
+ * 1. Fired ≥1 `cart_snapshot` event
55
+ * 2. Did NOT fire `checkout_completed` since
56
+ * 3. Last snapshot is older than `windowMinutes`
57
+ * 4. Not already covered by an `abandoned_cart` row with same sessionId
58
+ *
59
+ * Upserts a row per matched session. Marks any existing `abandoned`
60
+ * row as `converted` when a matching `checkout_completed` is seen.
61
+ * Fires the Slack notification for fresh rows above the threshold.
62
+ */
63
+ scan(): Promise<{
64
+ opened: number;
65
+ converted: number;
66
+ slacked: number;
67
+ }>;
68
+ /**
69
+ * Generate a signed recovery link that the storefront can decode to
70
+ * restore the cart. Returns null when the recovery-link secret is
71
+ * unset (feature disabled). Idempotent: reissues a fresh token so
72
+ * you can safely regenerate before every send.
73
+ */
74
+ issueRecoveryLink(cartId: number): Promise<string | null>;
75
+ /**
76
+ * Look up an abandoned cart by its recovery token. Returns null if
77
+ * the token is unknown, expired, or the cart is already recovered.
78
+ * Storefront calls this via `/ees/recover-cart?t=...` to rebuild the
79
+ * cart from the persisted itemsJson.
80
+ */
81
+ findByRecoveryToken(token: string): Promise<{
82
+ id: number;
83
+ currency: string;
84
+ items: any[];
85
+ email: string | null;
86
+ } | null>;
87
+ markStatus(cartId: number, status: AbandonedCartStatus): Promise<boolean>;
88
+ private parseMeta;
89
+ private postSlack;
90
+ private formatMoney;
91
+ }
92
+ //# sourceMappingURL=abandoned-cart.service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"abandoned-cart.service.d.ts","sourceRoot":"","sources":["../src/abandoned-cart.service.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,uBAAuB,EAAE,MAAM,eAAe,CAAC;AAExD,OAAO,EAAiB,mBAAmB,EAAE,MAAM,yBAAyB,CAAC;AAK7E,6EAA6E;AAC7E,MAAM,WAAW,gBAAgB;IAC7B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,KAAK,CAAC;QACV,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;QAC5B,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;QAC5B,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,GAAG,CAAC,EAAE,MAAM,CAAC;QACb,cAAc,CAAC,EAAE,MAAM,CAAC;QACxB,GAAG,CAAC,EAAE,MAAM,CAAC;KAChB,CAAC,CAAC;IACH,oDAAoD;IACpD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;;GAIG;AACH,MAAM,WAAW,kBAAkB;IAC/B;;;kBAGc;IACd,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;8CAE0C;IAC1C,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,oEAAoE;IACpE,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;kBAEc;IACd,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,8CAA8C;IAC9C,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;mEAC+D;IAC/D,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED,qBACa,oBAAoB;IACjB,OAAO,CAAC,UAAU;gBAAV,UAAU,EAAE,uBAAuB;IAEvD,OAAO,CAAC,IAAI;IAYZ;;;;;;;;;;;OAWG;IACG,IAAI,IAAI,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IAmJ7E;;;;;OAKG;IACG,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAmB/D;;;;;OAKG;IACG,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;QAC9C,EAAE,EAAE,MAAM,CAAC;QACX,QAAQ,EAAE,MAAM,CAAC;QACjB,KAAK,EAAE,GAAG,EAAE,CAAC;QACb,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;KACxB,GAAG,IAAI,CAAC;IAsBH,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,OAAO,CAAC;IAY/E,OAAO,CAAC,SAAS;YAYH,SAAS;IA4BvB,OAAO,CAAC,WAAW;CAUtB"}
@@ -0,0 +1,276 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __metadata = (this && this.__metadata) || function (k, v) {
9
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.AbandonedCartService = void 0;
13
+ const common_1 = require("@nestjs/common");
14
+ const core_1 = require("@vendure/core");
15
+ const crypto_1 = require("crypto");
16
+ const plugin_1 = require("./plugin");
17
+ const loggerCtx = 'HuloAbandonedCartService';
18
+ let AbandonedCartService = class AbandonedCartService {
19
+ constructor(connection) {
20
+ this.connection = connection;
21
+ }
22
+ opts() {
23
+ var _a, _b, _c, _d, _e, _f;
24
+ const raw = (0, plugin_1.getOptions)().abandonment;
25
+ return {
26
+ windowMinutes: (_a = raw === null || raw === void 0 ? void 0 : raw.windowMinutes) !== null && _a !== void 0 ? _a : 30,
27
+ slackMinValueMinor: (_b = raw === null || raw === void 0 ? void 0 : raw.slackMinValueMinor) !== null && _b !== void 0 ? _b : 5000,
28
+ slackWebhookUrl: (_c = raw === null || raw === void 0 ? void 0 : raw.slackWebhookUrl) !== null && _c !== void 0 ? _c : '',
29
+ recoveryLinkSecret: (_d = raw === null || raw === void 0 ? void 0 : raw.recoveryLinkSecret) !== null && _d !== void 0 ? _d : '',
30
+ recoveryLinkTtlHours: (_e = raw === null || raw === void 0 ? void 0 : raw.recoveryLinkTtlHours) !== null && _e !== void 0 ? _e : 72,
31
+ storefrontBaseUrl: (_f = raw === null || raw === void 0 ? void 0 : raw.storefrontBaseUrl) !== null && _f !== void 0 ? _f : (0, plugin_1.getOptions)().publicBaseUrl,
32
+ };
33
+ }
34
+ /**
35
+ * Full detection pass. Runs periodically (cron). Finds every session
36
+ * that:
37
+ * 1. Fired ≥1 `cart_snapshot` event
38
+ * 2. Did NOT fire `checkout_completed` since
39
+ * 3. Last snapshot is older than `windowMinutes`
40
+ * 4. Not already covered by an `abandoned_cart` row with same sessionId
41
+ *
42
+ * Upserts a row per matched session. Marks any existing `abandoned`
43
+ * row as `converted` when a matching `checkout_completed` is seen.
44
+ * Fires the Slack notification for fresh rows above the threshold.
45
+ */
46
+ async scan() {
47
+ var _a, _b, _c, _d, _e;
48
+ const o = this.opts();
49
+ const conn = this.connection.rawConnection;
50
+ const cutoff = new Date(Date.now() - o.windowMinutes * 60000);
51
+ // 1. Auto-mark existing abandoned rows as converted when a
52
+ // matching checkout_completed lands afterwards.
53
+ const converted = await conn.query(`UPDATE abandoned_cart ac
54
+ SET ac.status = 'converted', ac.recoveredAt = NOW()
55
+ WHERE ac.status = 'abandoned'
56
+ AND EXISTS (
57
+ SELECT 1 FROM visitor_event ve
58
+ WHERE ve.sessionId = ac.sessionId
59
+ AND ve.type = 'event'
60
+ AND ve.meta LIKE '%"eventType":"checkout_completed"%'
61
+ AND ve.createdAt > ac.abandonedAt
62
+ )`);
63
+ const convertedCount = Number((_b = (_a = converted === null || converted === void 0 ? void 0 : converted.affectedRows) !== null && _a !== void 0 ? _a : converted === null || converted === void 0 ? void 0 : converted[1]) !== null && _b !== void 0 ? _b : 0);
64
+ // 2. Find candidate sessions.
65
+ const candidates = await conn.query(`SELECT
66
+ ve.sessionId,
67
+ MAX(ve.visitorId) AS visitorId,
68
+ MAX(ve.customerId) AS customerId,
69
+ MAX(ve.channelId) AS channelId,
70
+ MIN(ve.createdAt) AS firstAt,
71
+ MAX(ve.createdAt) AS lastAt,
72
+ MAX(ve.url) AS lastUrl,
73
+ MAX(ve.referrer) AS lastReferrer,
74
+ MAX(ve.utmSource) AS utmSource,
75
+ MAX(ve.utmMedium) AS utmMedium,
76
+ MAX(ve.utmCampaign) AS utmCampaign,
77
+ MAX(ve.country) AS countryCode,
78
+ SUBSTRING_INDEX(GROUP_CONCAT(ve.meta ORDER BY ve.createdAt DESC SEPARATOR '¦'), '¦', 1) AS lastMeta
79
+ FROM visitor_event ve
80
+ WHERE ve.type = 'event'
81
+ AND ve.meta LIKE '%"eventType":"cart_snapshot"%'
82
+ AND ve.createdAt >= (NOW() - INTERVAL 48 HOUR)
83
+ AND ve.createdAt <= ?
84
+ AND NOT EXISTS (
85
+ SELECT 1 FROM visitor_event ve2
86
+ WHERE ve2.sessionId = ve.sessionId
87
+ AND ve2.type = 'event'
88
+ AND ve2.meta LIKE '%"eventType":"checkout_completed"%'
89
+ AND ve2.createdAt >= ve.createdAt
90
+ )
91
+ AND NOT EXISTS (
92
+ SELECT 1 FROM visitor_event ve3
93
+ WHERE ve3.sessionId = ve.sessionId
94
+ AND ve3.type = 'event'
95
+ AND ve3.meta LIKE '%"eventType":"cart_snapshot"%'
96
+ AND ve3.createdAt > ?
97
+ )
98
+ GROUP BY ve.sessionId
99
+ LIMIT 500`, [cutoff, cutoff]);
100
+ let opened = 0;
101
+ let slacked = 0;
102
+ for (const c of candidates) {
103
+ const meta = this.parseMeta(c.lastMeta);
104
+ if (!meta)
105
+ continue;
106
+ const totalMinor = Number(meta.totalMinor || 0);
107
+ const itemCount = Number((_e = (_c = meta.itemCount) !== null && _c !== void 0 ? _c : (_d = meta.items) === null || _d === void 0 ? void 0 : _d.length) !== null && _e !== void 0 ? _e : 0);
108
+ if (!itemCount)
109
+ continue; // empty cart isn't abandonment
110
+ const items = Array.isArray(meta.items) ? meta.items : [];
111
+ const email = (meta.email || '').trim().toLowerCase() || null;
112
+ const emailHash = email ? (0, crypto_1.createHash)('sha256').update(email).digest('hex') : null;
113
+ const existing = await conn.query(`SELECT id, status, notificationSent FROM abandoned_cart WHERE sessionId = ? LIMIT 1`, [c.sessionId]);
114
+ if (existing === null || existing === void 0 ? void 0 : existing.length) {
115
+ // Refresh in place — but never resurrect a converted/dismissed row.
116
+ if (existing[0].status !== 'abandoned')
117
+ continue;
118
+ await conn.query(`UPDATE abandoned_cart SET
119
+ totalMinor = ?, itemCount = ?, itemsJson = ?,
120
+ email = COALESCE(?, email),
121
+ emailHash = COALESCE(?, emailHash),
122
+ lastSnapshotAt = ?, lastKnownUrl = ?,
123
+ countryCode = COALESCE(?, countryCode),
124
+ updatedAt = NOW(3)
125
+ WHERE id = ?`, [
126
+ totalMinor, itemCount, JSON.stringify(items),
127
+ email, emailHash,
128
+ new Date(c.lastAt), c.lastUrl,
129
+ meta.countryCode || c.countryCode,
130
+ existing[0].id,
131
+ ]);
132
+ continue;
133
+ }
134
+ await conn.query(`INSERT INTO abandoned_cart (
135
+ visitorId, sessionId, customerId, channelId,
136
+ currency, totalMinor, itemCount, itemsJson,
137
+ email, emailHash,
138
+ firstSnapshotAt, lastSnapshotAt, abandonedAt,
139
+ status, lastKnownUrl, lastKnownReferrer,
140
+ utmSource, utmMedium, utmCampaign, countryCode,
141
+ notificationSent, createdAt, updatedAt
142
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(3), 'abandoned', ?, ?, ?, ?, ?, ?, 0, NOW(3), NOW(3))`, [
143
+ c.visitorId, c.sessionId, c.customerId, c.channelId || 1,
144
+ (meta.currency || 'GBP').slice(0, 3), totalMinor, itemCount, JSON.stringify(items),
145
+ email, emailHash,
146
+ new Date(c.firstAt), new Date(c.lastAt),
147
+ c.lastUrl, c.lastReferrer,
148
+ c.utmSource, c.utmMedium, c.utmCampaign,
149
+ meta.countryCode || c.countryCode,
150
+ ]);
151
+ opened += 1;
152
+ if (totalMinor >= o.slackMinValueMinor && o.slackWebhookUrl) {
153
+ try {
154
+ await this.postSlack(o.slackWebhookUrl, {
155
+ totalMinor, itemCount, items, email,
156
+ countryCode: meta.countryCode || c.countryCode,
157
+ lastUrl: c.lastUrl,
158
+ currency: (meta.currency || 'GBP').slice(0, 3),
159
+ });
160
+ await conn.query(`UPDATE abandoned_cart SET notificationSent = 1 WHERE sessionId = ?`, [c.sessionId]);
161
+ slacked += 1;
162
+ }
163
+ catch (e) {
164
+ common_1.Logger.warn(`Slack notify failed for session=${c.sessionId}: ${e === null || e === void 0 ? void 0 : e.message}`, loggerCtx);
165
+ }
166
+ }
167
+ }
168
+ if (opened || convertedCount) {
169
+ common_1.Logger.log(`Abandonment scan: +${opened} opened / +${convertedCount} converted / ${slacked} slacked`, loggerCtx);
170
+ }
171
+ return { opened, converted: convertedCount, slacked };
172
+ }
173
+ /**
174
+ * Generate a signed recovery link that the storefront can decode to
175
+ * restore the cart. Returns null when the recovery-link secret is
176
+ * unset (feature disabled). Idempotent: reissues a fresh token so
177
+ * you can safely regenerate before every send.
178
+ */
179
+ async issueRecoveryLink(cartId) {
180
+ const o = this.opts();
181
+ if (!o.recoveryLinkSecret)
182
+ return null;
183
+ const conn = this.connection.rawConnection;
184
+ const rows = await conn.query(`SELECT sessionId, visitorId FROM abandoned_cart WHERE id = ? LIMIT 1`, [cartId]);
185
+ if (!(rows === null || rows === void 0 ? void 0 : rows.length))
186
+ return null;
187
+ const token = (0, crypto_1.randomBytes)(24).toString('base64url');
188
+ const expiresAt = new Date(Date.now() + o.recoveryLinkTtlHours * 3600000);
189
+ await conn.query(`UPDATE abandoned_cart SET recoveryToken = ?, recoveryTokenExpiresAt = ? WHERE id = ?`, [token, expiresAt, cartId]);
190
+ const base = (o.storefrontBaseUrl || '').replace(/\/$/, '');
191
+ return `${base}/cart/restore?t=${token}`;
192
+ }
193
+ /**
194
+ * Look up an abandoned cart by its recovery token. Returns null if
195
+ * the token is unknown, expired, or the cart is already recovered.
196
+ * Storefront calls this via `/ees/recover-cart?t=...` to rebuild the
197
+ * cart from the persisted itemsJson.
198
+ */
199
+ async findByRecoveryToken(token) {
200
+ const conn = this.connection.rawConnection;
201
+ const rows = await conn.query(`SELECT id, currency, itemsJson, email, recoveryTokenExpiresAt, status
202
+ FROM abandoned_cart WHERE recoveryToken = ? LIMIT 1`, [token]);
203
+ if (!(rows === null || rows === void 0 ? void 0 : rows.length))
204
+ return null;
205
+ const r = rows[0];
206
+ if (r.status === 'expired' || r.status === 'converted')
207
+ return null;
208
+ if (r.recoveryTokenExpiresAt && new Date(r.recoveryTokenExpiresAt).getTime() < Date.now()) {
209
+ await conn.query(`UPDATE abandoned_cart SET status = 'expired' WHERE id = ?`, [r.id]);
210
+ return null;
211
+ }
212
+ let items = [];
213
+ try {
214
+ items = JSON.parse(r.itemsJson || '[]');
215
+ }
216
+ catch { }
217
+ return { id: Number(r.id), currency: String(r.currency || 'GBP'), items, email: r.email };
218
+ }
219
+ async markStatus(cartId, status) {
220
+ var _a;
221
+ const conn = this.connection.rawConnection;
222
+ const setRecovered = status === 'recovered' ? ', recoveredAt = NOW(3)' : '';
223
+ const res = await conn.query(`UPDATE abandoned_cart SET status = ? ${setRecovered} WHERE id = ?`, [status, cartId]);
224
+ return Number((_a = res === null || res === void 0 ? void 0 : res.affectedRows) !== null && _a !== void 0 ? _a : 0) > 0;
225
+ }
226
+ // ── helpers ─────────────────────────────────────────────────────
227
+ parseMeta(raw) {
228
+ if (!raw)
229
+ return null;
230
+ try {
231
+ const parsed = JSON.parse(raw);
232
+ // Custom-event rows have `{ eventType, ... }` — the payload
233
+ // is the rest of the object.
234
+ return parsed;
235
+ }
236
+ catch {
237
+ return null;
238
+ }
239
+ }
240
+ async postSlack(url, payload) {
241
+ const money = this.formatMoney(payload.totalMinor, payload.currency);
242
+ const itemList = payload.items.slice(0, 5)
243
+ .map(i => { var _a, _b; return `• ${(_a = i.qty) !== null && _a !== void 0 ? _a : 1}× ${(_b = i.name) !== null && _b !== void 0 ? _b : '(unnamed item)'}`; })
244
+ .join('\n');
245
+ const more = payload.items.length > 5 ? `\n_…and ${payload.items.length - 5} more_` : '';
246
+ const parts = [
247
+ `🛒 *Abandoned cart* — ${money} · ${payload.itemCount} item${payload.itemCount === 1 ? '' : 's'}`,
248
+ payload.email ? `Contact: \`${payload.email}\`` : '_(no email captured)_',
249
+ payload.countryCode ? `Country: ${payload.countryCode}` : '',
250
+ payload.lastUrl ? `Last URL: ${payload.lastUrl}` : '',
251
+ itemList + more,
252
+ ].filter(Boolean);
253
+ await fetch(url, {
254
+ method: 'POST',
255
+ headers: { 'content-type': 'application/json' },
256
+ body: JSON.stringify({ text: parts.join('\n') }),
257
+ });
258
+ }
259
+ formatMoney(minor, currency) {
260
+ const major = (minor || 0) / 100;
261
+ try {
262
+ return new Intl.NumberFormat('en-GB', {
263
+ style: 'currency', currency,
264
+ }).format(major);
265
+ }
266
+ catch {
267
+ return `${currency} ${major.toFixed(2)}`;
268
+ }
269
+ }
270
+ };
271
+ exports.AbandonedCartService = AbandonedCartService;
272
+ exports.AbandonedCartService = AbandonedCartService = __decorate([
273
+ (0, common_1.Injectable)(),
274
+ __metadata("design:paramtypes", [core_1.TransactionalConnection])
275
+ ], AbandonedCartService);
276
+ //# sourceMappingURL=abandoned-cart.service.js.map