@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.
- package/CHANGELOG.md +83 -15
- package/dist/abandoned-cart.controller.d.ts +78 -0
- package/dist/abandoned-cart.controller.d.ts.map +1 -0
- package/dist/abandoned-cart.controller.js +251 -0
- package/dist/abandoned-cart.controller.js.map +1 -0
- package/dist/abandoned-cart.entity.d.ts +61 -0
- package/dist/abandoned-cart.entity.d.ts.map +1 -0
- package/dist/abandoned-cart.entity.js +143 -0
- package/dist/abandoned-cart.entity.js.map +1 -0
- package/dist/abandoned-cart.scanner.d.ts +34 -0
- package/dist/abandoned-cart.scanner.d.ts.map +1 -0
- package/dist/abandoned-cart.scanner.js +85 -0
- package/dist/abandoned-cart.scanner.js.map +1 -0
- package/dist/abandoned-cart.service.d.ts +92 -0
- package/dist/abandoned-cart.service.d.ts.map +1 -0
- package/dist/abandoned-cart.service.js +276 -0
- package/dist/abandoned-cart.service.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -1
- package/dist/index.js.map +1 -1
- package/dist/journey-buffs.controller.d.ts +43 -0
- package/dist/journey-buffs.controller.d.ts.map +1 -0
- package/dist/journey-buffs.controller.js +79 -0
- package/dist/journey-buffs.controller.js.map +1 -0
- package/dist/journey-buffs.service.d.ts +53 -0
- package/dist/journey-buffs.service.d.ts.map +1 -0
- package/dist/journey-buffs.service.js +141 -0
- package/dist/journey-buffs.service.js.map +1 -0
- package/dist/plugin.d.ts +5 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +28 -3
- package/dist/plugin.js.map +1 -1
- package/dist/product-co-view.entity.d.ts +19 -0
- package/dist/product-co-view.entity.d.ts.map +1 -0
- package/dist/product-co-view.entity.js +52 -0
- package/dist/product-co-view.entity.js.map +1 -0
- package/dist/recommendations.controller.d.ts +72 -0
- package/dist/recommendations.controller.d.ts.map +1 -0
- package/dist/recommendations.controller.js +123 -0
- package/dist/recommendations.controller.js.map +1 -0
- package/dist/recommendations.service.d.ts +62 -0
- package/dist/recommendations.service.d.ts.map +1 -0
- package/dist/recommendations.service.js +167 -0
- package/dist/recommendations.service.js.map +1 -0
- package/dist/search-analytics.controller.d.ts +36 -0
- package/dist/search-analytics.controller.d.ts.map +1 -0
- package/dist/search-analytics.controller.js +86 -0
- package/dist/search-analytics.controller.js.map +1 -0
- package/dist/search-analytics.service.d.ts +36 -0
- package/dist/search-analytics.service.d.ts.map +1 -0
- package/dist/search-analytics.service.js +105 -0
- package/dist/search-analytics.service.js.map +1 -0
- 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
|