@huloglobal/vendure-plugin-visitor-analytics 0.2.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 +39 -0
- package/LICENSE +34 -0
- package/README.md +167 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/plugin.d.ts +47 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +96 -0
- package/dist/plugin.js.map +1 -0
- package/dist/proxy-headers.d.ts +37 -0
- package/dist/proxy-headers.d.ts.map +1 -0
- package/dist/proxy-headers.js +81 -0
- package/dist/proxy-headers.js.map +1 -0
- package/dist/visitor-event.entity.d.ts +68 -0
- package/dist/visitor-event.entity.d.ts.map +1 -0
- package/dist/visitor-event.entity.js +163 -0
- package/dist/visitor-event.entity.js.map +1 -0
- package/dist/visitor-tracking.controller.d.ts +66 -0
- package/dist/visitor-tracking.controller.d.ts.map +1 -0
- package/dist/visitor-tracking.controller.js +658 -0
- package/dist/visitor-tracking.controller.js.map +1 -0
- package/dist/visitor-tracking.service.d.ts +41 -0
- package/dist/visitor-tracking.service.d.ts.map +1 -0
- package/dist/visitor-tracking.service.js +251 -0
- package/dist/visitor-tracking.service.js.map +1 -0
- package/package.json +51 -0
- package/ui/components/visitors.component.ts +695 -0
- package/ui/visitors.module.ts +17 -0
|
@@ -0,0 +1,658 @@
|
|
|
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
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
12
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.VisitorTrackingController = void 0;
|
|
16
|
+
const common_1 = require("@nestjs/common");
|
|
17
|
+
const core_1 = require("@vendure/core");
|
|
18
|
+
const visitor_event_entity_1 = require("./visitor-event.entity");
|
|
19
|
+
const visitor_tracking_service_1 = require("./visitor-tracking.service");
|
|
20
|
+
const COOKIE_VISITOR = 'ees_vid';
|
|
21
|
+
const COOKIE_SESSION = 'ees_sid';
|
|
22
|
+
const VISITOR_TTL_DAYS = 730; // ~2 years
|
|
23
|
+
const SESSION_IDLE_MIN = 30; // 30-minute idle session timeout
|
|
24
|
+
function requireAdmin(ctx, res) {
|
|
25
|
+
if (!(ctx === null || ctx === void 0 ? void 0 : ctx.activeUserId)) {
|
|
26
|
+
res.status(401).json({ error: 'Authentication required' });
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
if (!ctx.userHasPermissions([core_1.Permission.ReadCustomer])) {
|
|
30
|
+
res.status(403).json({ error: 'Insufficient permissions' });
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
function clampInt(raw, fallback, min, max) {
|
|
36
|
+
const n = parseInt(String(raw !== null && raw !== void 0 ? raw : fallback), 10);
|
|
37
|
+
if (isNaN(n))
|
|
38
|
+
return fallback;
|
|
39
|
+
return Math.max(min, Math.min(max, n));
|
|
40
|
+
}
|
|
41
|
+
const proxy_headers_1 = require("./proxy-headers");
|
|
42
|
+
function realIp(req) { return (0, proxy_headers_1.getRealIp)(req); }
|
|
43
|
+
let VisitorTrackingController = class VisitorTrackingController {
|
|
44
|
+
constructor(connection, tracking) {
|
|
45
|
+
this.connection = connection;
|
|
46
|
+
this.tracking = tracking;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Ingest endpoint hit by the storefront tracker. Anonymous, takes
|
|
50
|
+
* a small JSON batch via fetch() or navigator.sendBeacon() (which
|
|
51
|
+
* uses text/plain). CORS is open because the storefront is on a
|
|
52
|
+
* different origin from the API.
|
|
53
|
+
*
|
|
54
|
+
* POST /ees/track
|
|
55
|
+
* body: {
|
|
56
|
+
* channelId?: number,
|
|
57
|
+
* customerId?: number | null,
|
|
58
|
+
* events: [
|
|
59
|
+
* { type, url, title?, referrer?, timeOnPageMs?, meta?, clientTs? }
|
|
60
|
+
* ]
|
|
61
|
+
* }
|
|
62
|
+
*
|
|
63
|
+
* Cookies `ees_vid` (visitor) and `ees_sid` (session) are issued
|
|
64
|
+
* on the first request and refreshed on every subsequent one.
|
|
65
|
+
*/
|
|
66
|
+
async track(body, req, res) {
|
|
67
|
+
// Lenient CORS — analytics ingestion must work cross-origin from
|
|
68
|
+
// both storefronts (and dev hosts) without complex config.
|
|
69
|
+
this.applyCors(req, res);
|
|
70
|
+
if (req.method === 'OPTIONS')
|
|
71
|
+
return res.status(204).end();
|
|
72
|
+
const cookies = this.parseCookies(req);
|
|
73
|
+
let visitorId = String(cookies[COOKIE_VISITOR] || '').slice(0, 64);
|
|
74
|
+
let sessionId = String(cookies[COOKIE_SESSION] || '').slice(0, 64);
|
|
75
|
+
let issuedVisitor = false;
|
|
76
|
+
let issuedSession = false;
|
|
77
|
+
if (!visitorId || !/^[a-f0-9]{16,64}$/i.test(visitorId)) {
|
|
78
|
+
visitorId = this.tracking.newId();
|
|
79
|
+
issuedVisitor = true;
|
|
80
|
+
}
|
|
81
|
+
if (!sessionId || !/^[a-f0-9]{16,64}$/i.test(sessionId)) {
|
|
82
|
+
sessionId = this.tracking.newId();
|
|
83
|
+
issuedSession = true;
|
|
84
|
+
}
|
|
85
|
+
const channelId = Number(body === null || body === void 0 ? void 0 : body.channelId) || 1;
|
|
86
|
+
const customerId = (body === null || body === void 0 ? void 0 : body.customerId) != null ? Number(body.customerId) || null : null;
|
|
87
|
+
const events = Array.isArray(body === null || body === void 0 ? void 0 : body.events) ? body.events.slice(0, 50) : [];
|
|
88
|
+
const ip = realIp(req);
|
|
89
|
+
// Prefer the upstream proxy's resolved country / region (Cloudflare,
|
|
90
|
+
// Akamai, Fastly all populate these headers when configured) — saves
|
|
91
|
+
// the per-request MaxMind lookup. The service still falls back to a
|
|
92
|
+
// GeoLite2 lookup if the proxy values are absent.
|
|
93
|
+
const proxyCountry = (0, proxy_headers_1.getResolvedCountry)(req);
|
|
94
|
+
const proxyRegion = (0, proxy_headers_1.getResolvedRegion)(req);
|
|
95
|
+
const out = await this.tracking.ingest({
|
|
96
|
+
visitorId, sessionId, customerId, channelId, events,
|
|
97
|
+
ip,
|
|
98
|
+
userAgent: req.headers['user-agent'] || null,
|
|
99
|
+
acceptLanguage: req.headers['accept-language'] || null,
|
|
100
|
+
proxyCountry, proxyRegion,
|
|
101
|
+
});
|
|
102
|
+
// Refresh cookies on every hit so the session window slides while
|
|
103
|
+
// the visitor is active.
|
|
104
|
+
const cookieOpts = (maxAgeSec) => `Path=/; Max-Age=${maxAgeSec}; SameSite=Lax`;
|
|
105
|
+
res.setHeader('Set-Cookie', [
|
|
106
|
+
`${COOKIE_VISITOR}=${visitorId}; ${cookieOpts(VISITOR_TTL_DAYS * 86400)}`,
|
|
107
|
+
`${COOKIE_SESSION}=${sessionId}; ${cookieOpts(SESSION_IDLE_MIN * 60)}`,
|
|
108
|
+
]);
|
|
109
|
+
return res.json({
|
|
110
|
+
stored: out.stored,
|
|
111
|
+
visitorId, sessionId,
|
|
112
|
+
issuedVisitor, issuedSession,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
// ------------------------------------------------------------------
|
|
116
|
+
// Admin reads — feed the Visitor Journey UI.
|
|
117
|
+
// ------------------------------------------------------------------
|
|
118
|
+
/** Top-line counters + daily series for the dashboard header. */
|
|
119
|
+
async summary(ctx, req, res) {
|
|
120
|
+
if (!requireAdmin(ctx, res))
|
|
121
|
+
return;
|
|
122
|
+
const days = Math.min(Math.max(parseInt(String(req.query.days || '30'), 10) || 30, 1), 365);
|
|
123
|
+
const rows = await this.connection.rawConnection.query(`SELECT DATE(createdAt) AS day,
|
|
124
|
+
COUNT(DISTINCT visitorId) AS visitors,
|
|
125
|
+
COUNT(DISTINCT sessionId) AS sessions,
|
|
126
|
+
COUNT(*) AS events,
|
|
127
|
+
SUM(type='pageview') AS pageviews
|
|
128
|
+
FROM visitor_event
|
|
129
|
+
WHERE createdAt >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
|
130
|
+
GROUP BY DATE(createdAt)
|
|
131
|
+
ORDER BY day`, [days]);
|
|
132
|
+
const [{ totalVisitors, totalSessions, totalPageviews, avgTimeMs }] = await this.connection.rawConnection.query(`SELECT COUNT(DISTINCT visitorId) AS totalVisitors,
|
|
133
|
+
COUNT(DISTINCT sessionId) AS totalSessions,
|
|
134
|
+
SUM(type='pageview') AS totalPageviews,
|
|
135
|
+
AVG(CASE WHEN type='unload' AND timeOnPageMs > 0 THEN timeOnPageMs END) AS avgTimeMs
|
|
136
|
+
FROM visitor_event
|
|
137
|
+
WHERE createdAt >= DATE_SUB(NOW(), INTERVAL ? DAY)`, [days]);
|
|
138
|
+
return res.json({
|
|
139
|
+
days,
|
|
140
|
+
totals: {
|
|
141
|
+
visitors: Number(totalVisitors) || 0,
|
|
142
|
+
sessions: Number(totalSessions) || 0,
|
|
143
|
+
pageviews: Number(totalPageviews) || 0,
|
|
144
|
+
avgTimeMs: Math.round(Number(avgTimeMs) || 0),
|
|
145
|
+
},
|
|
146
|
+
daily: rows.map((r) => ({
|
|
147
|
+
day: r.day,
|
|
148
|
+
visitors: Number(r.visitors) || 0,
|
|
149
|
+
sessions: Number(r.sessions) || 0,
|
|
150
|
+
events: Number(r.events) || 0,
|
|
151
|
+
pageviews: Number(r.pageviews) || 0,
|
|
152
|
+
})),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
/** Traffic sources — UTM source breakdown + organic referrer
|
|
156
|
+
* domains. Visitors are attributed by their first-pageview's UTM
|
|
157
|
+
* / referrer combo per session. */
|
|
158
|
+
async sources(ctx, req, res) {
|
|
159
|
+
if (!requireAdmin(ctx, res))
|
|
160
|
+
return;
|
|
161
|
+
const days = clampInt(req.query.days, 30, 1, 365);
|
|
162
|
+
const take = clampInt(req.query.take, 25, 1, 500);
|
|
163
|
+
const skip = clampInt(req.query.skip, 0, 0, 1000000);
|
|
164
|
+
const rows = await this.connection.rawConnection.query(`SELECT source, medium, COALESCE(MAX(campaign), '') AS campaign,
|
|
165
|
+
COUNT(DISTINCT visitorId) AS visitors,
|
|
166
|
+
COUNT(DISTINCT sessionId) AS sessions,
|
|
167
|
+
SUM(reached_product) AS productViewers,
|
|
168
|
+
SUM(reached_checkout) AS checkoutReached
|
|
169
|
+
FROM (
|
|
170
|
+
SELECT visitorId, sessionId,
|
|
171
|
+
COALESCE(utmSource, referrerDomain, '(direct)') AS source,
|
|
172
|
+
COALESCE(utmMedium, IF(referrerDomain IS NOT NULL, 'referral', 'none')) AS medium,
|
|
173
|
+
utmCampaign AS campaign,
|
|
174
|
+
MAX(CASE WHEN url LIKE '/products/%' AND type='pageview' THEN 1 ELSE 0 END) AS reached_product,
|
|
175
|
+
MAX(CASE WHEN (url LIKE '%/checkout%' OR url LIKE '%cart%') AND type='pageview' THEN 1 ELSE 0 END) AS reached_checkout
|
|
176
|
+
FROM visitor_event
|
|
177
|
+
WHERE createdAt >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
|
178
|
+
GROUP BY visitorId, sessionId,
|
|
179
|
+
COALESCE(utmSource, referrerDomain, '(direct)'),
|
|
180
|
+
COALESCE(utmMedium, IF(referrerDomain IS NOT NULL, 'referral', 'none')),
|
|
181
|
+
utmCampaign
|
|
182
|
+
) by_session
|
|
183
|
+
GROUP BY source, medium
|
|
184
|
+
ORDER BY visitors DESC
|
|
185
|
+
LIMIT ? OFFSET ?`, [days, take, skip]);
|
|
186
|
+
const [{ total }] = await this.connection.rawConnection.query(`SELECT COUNT(*) AS total FROM (
|
|
187
|
+
SELECT DISTINCT
|
|
188
|
+
COALESCE(utmSource, referrerDomain, '(direct)') AS source,
|
|
189
|
+
COALESCE(utmMedium, IF(referrerDomain IS NOT NULL, 'referral', 'none')) AS medium
|
|
190
|
+
FROM visitor_event
|
|
191
|
+
WHERE createdAt >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
|
192
|
+
) sources`, [days]);
|
|
193
|
+
return res.json({
|
|
194
|
+
sources: rows.map((r) => ({
|
|
195
|
+
source: r.source,
|
|
196
|
+
medium: r.medium,
|
|
197
|
+
campaign: r.campaign,
|
|
198
|
+
visitors: Number(r.visitors) || 0,
|
|
199
|
+
sessions: Number(r.sessions) || 0,
|
|
200
|
+
productViewers: Number(r.productViewers) || 0,
|
|
201
|
+
checkoutReached: Number(r.checkoutReached) || 0,
|
|
202
|
+
})),
|
|
203
|
+
total: Number(total) || 0, take, skip,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
/** Top pages by view count. Paginated via take + skip. */
|
|
207
|
+
async topPages(ctx, req, res) {
|
|
208
|
+
if (!requireAdmin(ctx, res))
|
|
209
|
+
return;
|
|
210
|
+
const days = clampInt(req.query.days, 30, 1, 365);
|
|
211
|
+
const take = clampInt(req.query.take, 25, 1, 500);
|
|
212
|
+
const skip = clampInt(req.query.skip, 0, 0, 1000000);
|
|
213
|
+
const rows = await this.connection.rawConnection.query(`SELECT url,
|
|
214
|
+
MAX(title) AS title,
|
|
215
|
+
COUNT(*) AS views,
|
|
216
|
+
COUNT(DISTINCT visitorId) AS uniqueVisitors,
|
|
217
|
+
AVG(NULLIF(timeOnPageMs, 0)) AS avgTimeMs
|
|
218
|
+
FROM visitor_event
|
|
219
|
+
WHERE createdAt >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
|
220
|
+
AND type IN ('pageview', 'unload')
|
|
221
|
+
GROUP BY url
|
|
222
|
+
ORDER BY views DESC
|
|
223
|
+
LIMIT ? OFFSET ?`, [days, take, skip]);
|
|
224
|
+
const [{ total }] = await this.connection.rawConnection.query(`SELECT COUNT(DISTINCT url) AS total FROM visitor_event
|
|
225
|
+
WHERE createdAt >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
|
226
|
+
AND type IN ('pageview', 'unload')`, [days]);
|
|
227
|
+
return res.json({
|
|
228
|
+
pages: rows.map((r) => ({
|
|
229
|
+
url: r.url, title: r.title,
|
|
230
|
+
views: Number(r.views) || 0,
|
|
231
|
+
uniqueVisitors: Number(r.uniqueVisitors) || 0,
|
|
232
|
+
avgTimeMs: Math.round(Number(r.avgTimeMs) || 0),
|
|
233
|
+
})),
|
|
234
|
+
total: Number(total) || 0, take, skip,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
/** Funnel: visitors → product views → cart → checkout completed. */
|
|
238
|
+
async funnel(ctx, req, res) {
|
|
239
|
+
if (!requireAdmin(ctx, res))
|
|
240
|
+
return;
|
|
241
|
+
const days = Math.min(Math.max(parseInt(String(req.query.days || '30'), 10) || 30, 1), 365);
|
|
242
|
+
const stages = [
|
|
243
|
+
{ key: 'visited', label: 'Any page', where: `1=1` },
|
|
244
|
+
{ key: 'viewed', label: 'Viewed a product', where: `url LIKE '/products/%'` },
|
|
245
|
+
{ key: 'cart', label: 'Opened the cart', where: `url LIKE '%/checkout%' OR url LIKE '%cart%'` },
|
|
246
|
+
{ key: 'confirmed', label: 'Reached checkout confirmation', where: `url LIKE '%/checkout/confirmation/%'` },
|
|
247
|
+
];
|
|
248
|
+
const result = [];
|
|
249
|
+
for (const s of stages) {
|
|
250
|
+
const [{ n }] = await this.connection.rawConnection.query(`SELECT COUNT(DISTINCT visitorId) AS n FROM visitor_event
|
|
251
|
+
WHERE createdAt >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
|
252
|
+
AND type='pageview' AND (${s.where})`, [days]);
|
|
253
|
+
result.push({ key: s.key, label: s.label, visitors: Number(n) || 0 });
|
|
254
|
+
}
|
|
255
|
+
return res.json({ days, stages: result });
|
|
256
|
+
}
|
|
257
|
+
/** Exit pages — pages where the last event in the session was a
|
|
258
|
+
* pageview. Paginated via take + skip. */
|
|
259
|
+
async exitPages(ctx, req, res) {
|
|
260
|
+
if (!requireAdmin(ctx, res))
|
|
261
|
+
return;
|
|
262
|
+
const days = clampInt(req.query.days, 30, 1, 365);
|
|
263
|
+
const take = clampInt(req.query.take, 25, 1, 500);
|
|
264
|
+
const skip = clampInt(req.query.skip, 0, 0, 1000000);
|
|
265
|
+
const rows = await this.connection.rawConnection.query(`SELECT url,
|
|
266
|
+
MAX(title) AS title,
|
|
267
|
+
COUNT(*) AS exits
|
|
268
|
+
FROM (
|
|
269
|
+
SELECT sessionId, url, title, createdAt,
|
|
270
|
+
ROW_NUMBER() OVER (PARTITION BY sessionId ORDER BY createdAt DESC) AS rn
|
|
271
|
+
FROM visitor_event
|
|
272
|
+
WHERE createdAt >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
|
273
|
+
AND type='pageview'
|
|
274
|
+
) last_pages
|
|
275
|
+
WHERE rn = 1
|
|
276
|
+
GROUP BY url
|
|
277
|
+
ORDER BY exits DESC
|
|
278
|
+
LIMIT ? OFFSET ?`, [days, take, skip]);
|
|
279
|
+
const [{ total }] = await this.connection.rawConnection.query(`SELECT COUNT(DISTINCT url) AS total FROM (
|
|
280
|
+
SELECT sessionId, url,
|
|
281
|
+
ROW_NUMBER() OVER (PARTITION BY sessionId ORDER BY createdAt DESC) AS rn
|
|
282
|
+
FROM visitor_event
|
|
283
|
+
WHERE createdAt >= DATE_SUB(NOW(), INTERVAL ? DAY) AND type='pageview'
|
|
284
|
+
) lp WHERE rn = 1`, [days]);
|
|
285
|
+
return res.json({
|
|
286
|
+
exitPages: rows.map((r) => ({
|
|
287
|
+
url: r.url, title: r.title, exits: Number(r.exits) || 0,
|
|
288
|
+
})),
|
|
289
|
+
total: Number(total) || 0, take, skip,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
/** Top custom events — `type` other than pageview / unload. Useful
|
|
293
|
+
* for tracking add-to-cart / search / quote-request / signup
|
|
294
|
+
* conversion rates. Paginated. */
|
|
295
|
+
async topEvents(ctx, req, res) {
|
|
296
|
+
if (!requireAdmin(ctx, res))
|
|
297
|
+
return;
|
|
298
|
+
const days = clampInt(req.query.days, 30, 1, 365);
|
|
299
|
+
const take = clampInt(req.query.take, 25, 1, 500);
|
|
300
|
+
const skip = clampInt(req.query.skip, 0, 0, 1000000);
|
|
301
|
+
const rows = await this.connection.rawConnection.query(`SELECT type,
|
|
302
|
+
COUNT(*) AS count,
|
|
303
|
+
COUNT(DISTINCT visitorId) AS uniqueVisitors,
|
|
304
|
+
COUNT(DISTINCT sessionId) AS sessions
|
|
305
|
+
FROM visitor_event
|
|
306
|
+
WHERE createdAt >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
|
307
|
+
AND type NOT IN ('pageview', 'unload')
|
|
308
|
+
GROUP BY type
|
|
309
|
+
ORDER BY count DESC
|
|
310
|
+
LIMIT ? OFFSET ?`, [days, take, skip]);
|
|
311
|
+
const [{ total }] = await this.connection.rawConnection.query(`SELECT COUNT(DISTINCT type) AS total FROM visitor_event
|
|
312
|
+
WHERE createdAt >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
|
313
|
+
AND type NOT IN ('pageview', 'unload')`, [days]);
|
|
314
|
+
return res.json({
|
|
315
|
+
events: rows.map((r) => ({
|
|
316
|
+
type: r.type,
|
|
317
|
+
count: Number(r.count) || 0,
|
|
318
|
+
uniqueVisitors: Number(r.uniqueVisitors) || 0,
|
|
319
|
+
sessions: Number(r.sessions) || 0,
|
|
320
|
+
})),
|
|
321
|
+
total: Number(total) || 0, take, skip,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Live-now widget — Server-Sent Events stream pushing the current
|
|
326
|
+
* active-visitor count + their most recent URLs every 5 seconds.
|
|
327
|
+
* "Active" = at least one event in the last 5 minutes. SSE keeps
|
|
328
|
+
* the connection open; the admin UI consumes it via `EventSource`.
|
|
329
|
+
*
|
|
330
|
+
* Each event payload:
|
|
331
|
+
* data: { ts, activeCount, recent: [{ visitorId, url, country, secondsAgo }] }
|
|
332
|
+
*/
|
|
333
|
+
async live(ctx, req, res) {
|
|
334
|
+
var _a;
|
|
335
|
+
if (!requireAdmin(ctx, res))
|
|
336
|
+
return;
|
|
337
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
338
|
+
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
339
|
+
res.setHeader('Connection', 'keep-alive');
|
|
340
|
+
res.setHeader('X-Accel-Buffering', 'no'); // disable nginx buffering
|
|
341
|
+
(_a = res.flushHeaders) === null || _a === void 0 ? void 0 : _a.call(res);
|
|
342
|
+
const tick = async () => {
|
|
343
|
+
try {
|
|
344
|
+
const rows = await this.connection.rawConnection.query(`SELECT visitorId,
|
|
345
|
+
MAX(url) AS url,
|
|
346
|
+
MAX(country) AS country,
|
|
347
|
+
TIMESTAMPDIFF(SECOND, MAX(createdAt), NOW()) AS secondsAgo
|
|
348
|
+
FROM visitor_event
|
|
349
|
+
WHERE createdAt >= DATE_SUB(NOW(), INTERVAL 5 MINUTE)
|
|
350
|
+
AND type = 'pageview'
|
|
351
|
+
GROUP BY visitorId
|
|
352
|
+
ORDER BY MAX(createdAt) DESC
|
|
353
|
+
LIMIT 20`);
|
|
354
|
+
const payload = {
|
|
355
|
+
ts: new Date().toISOString(),
|
|
356
|
+
activeCount: rows.length,
|
|
357
|
+
recent: rows.map((r) => ({
|
|
358
|
+
visitorId: r.visitorId,
|
|
359
|
+
url: r.url,
|
|
360
|
+
country: r.country,
|
|
361
|
+
secondsAgo: Number(r.secondsAgo) || 0,
|
|
362
|
+
})),
|
|
363
|
+
};
|
|
364
|
+
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
// Don't kill the stream on transient DB errors — the next
|
|
368
|
+
// tick will retry. SSE clients reconnect automatically
|
|
369
|
+
// if the connection drops.
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
// Fire immediately, then every 5s. Stop when the client disconnects.
|
|
373
|
+
await tick();
|
|
374
|
+
const interval = setInterval(() => { tick().catch(() => undefined); }, 5000);
|
|
375
|
+
req.on('close', () => clearInterval(interval));
|
|
376
|
+
req.on('end', () => clearInterval(interval));
|
|
377
|
+
}
|
|
378
|
+
/** Journey timeline for one visitor — every event in order. */
|
|
379
|
+
async journey(ctx, visitorId, res) {
|
|
380
|
+
if (!requireAdmin(ctx, res))
|
|
381
|
+
return;
|
|
382
|
+
const events = await this.connection.rawConnection.getRepository(visitor_event_entity_1.VisitorEvent).find({
|
|
383
|
+
where: { visitorId: String(visitorId).slice(0, 64) },
|
|
384
|
+
order: { createdAt: 'ASC' },
|
|
385
|
+
take: 1000,
|
|
386
|
+
});
|
|
387
|
+
return res.json({ visitorId, events });
|
|
388
|
+
}
|
|
389
|
+
/** Recent visitors — paginated via take + skip. */
|
|
390
|
+
async recent(ctx, req, res) {
|
|
391
|
+
if (!requireAdmin(ctx, res))
|
|
392
|
+
return;
|
|
393
|
+
const days = clampInt(req.query.days, 7, 1, 365);
|
|
394
|
+
const take = clampInt(req.query.take, 25, 1, 500);
|
|
395
|
+
const skip = clampInt(req.query.skip, 0, 0, 1000000);
|
|
396
|
+
const rows = await this.connection.rawConnection.query(`SELECT visitorId,
|
|
397
|
+
MAX(customerId) AS customerId,
|
|
398
|
+
MIN(createdAt) AS firstSeenAt,
|
|
399
|
+
MAX(createdAt) AS lastSeenAt,
|
|
400
|
+
COUNT(DISTINCT sessionId) AS sessions,
|
|
401
|
+
SUM(type='pageview') AS pageviews,
|
|
402
|
+
MAX(country) AS country,
|
|
403
|
+
MAX(city) AS city,
|
|
404
|
+
MAX(browser) AS browser,
|
|
405
|
+
MAX(os) AS os,
|
|
406
|
+
MAX(device) AS device
|
|
407
|
+
FROM visitor_event
|
|
408
|
+
WHERE createdAt >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
|
409
|
+
GROUP BY visitorId
|
|
410
|
+
ORDER BY MAX(createdAt) DESC
|
|
411
|
+
LIMIT ? OFFSET ?`, [days, take, skip]);
|
|
412
|
+
const [{ total }] = await this.connection.rawConnection.query(`SELECT COUNT(DISTINCT visitorId) AS total
|
|
413
|
+
FROM visitor_event WHERE createdAt >= DATE_SUB(NOW(), INTERVAL ? DAY)`, [days]);
|
|
414
|
+
return res.json({
|
|
415
|
+
visitors: rows.map((r) => ({
|
|
416
|
+
visitorId: r.visitorId,
|
|
417
|
+
customerId: r.customerId,
|
|
418
|
+
firstSeenAt: r.firstSeenAt,
|
|
419
|
+
lastSeenAt: r.lastSeenAt,
|
|
420
|
+
sessions: Number(r.sessions) || 0,
|
|
421
|
+
pageviews: Number(r.pageviews) || 0,
|
|
422
|
+
country: r.country,
|
|
423
|
+
city: r.city,
|
|
424
|
+
browser: r.browser,
|
|
425
|
+
os: r.os,
|
|
426
|
+
device: r.device,
|
|
427
|
+
})),
|
|
428
|
+
total: Number(total) || 0, take, skip,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
/** Full visitor profile — every available signal we have on this
|
|
432
|
+
* visitor, plus the most-recent IP / UA / location / locale,
|
|
433
|
+
* customer link, and per-session breakdown. Drives the clickable
|
|
434
|
+
* detail drawer in the admin UI. */
|
|
435
|
+
async profile(ctx, visitorIdRaw, res) {
|
|
436
|
+
if (!requireAdmin(ctx, res))
|
|
437
|
+
return;
|
|
438
|
+
const visitorId = String(visitorIdRaw).slice(0, 64);
|
|
439
|
+
// One row with the latest non-null value of every column.
|
|
440
|
+
const [latest] = await this.connection.rawConnection.query(`SELECT visitorId,
|
|
441
|
+
MAX(customerId) AS customerId,
|
|
442
|
+
MIN(createdAt) AS firstSeenAt,
|
|
443
|
+
MAX(createdAt) AS lastSeenAt,
|
|
444
|
+
COUNT(DISTINCT sessionId) AS totalSessions,
|
|
445
|
+
SUM(type='pageview') AS totalPageviews,
|
|
446
|
+
SUM(type='unload') AS totalUnloads,
|
|
447
|
+
SUM(type='event') AS totalEvents,
|
|
448
|
+
SUM(timeOnPageMs) AS totalTimeMs,
|
|
449
|
+
MAX(ip) AS ip,
|
|
450
|
+
MAX(ipHash) AS ipHash,
|
|
451
|
+
MAX(userAgent) AS userAgent,
|
|
452
|
+
MAX(browser) AS browser,
|
|
453
|
+
MAX(browserVersion) AS browserVersion,
|
|
454
|
+
MAX(os) AS os,
|
|
455
|
+
MAX(osVersion) AS osVersion,
|
|
456
|
+
MAX(device) AS device,
|
|
457
|
+
MAX(acceptLanguage) AS acceptLanguage,
|
|
458
|
+
MAX(country) AS country,
|
|
459
|
+
MAX(region) AS region,
|
|
460
|
+
MAX(city) AS city,
|
|
461
|
+
MAX(timezone) AS timezone,
|
|
462
|
+
MAX(channelId) AS channelId
|
|
463
|
+
FROM visitor_event
|
|
464
|
+
WHERE visitorId = ?
|
|
465
|
+
GROUP BY visitorId`, [visitorId]);
|
|
466
|
+
if (!latest)
|
|
467
|
+
return res.status(404).json({ error: 'visitor not found' });
|
|
468
|
+
const sessions = await this.connection.rawConnection.query(`SELECT sessionId,
|
|
469
|
+
MIN(createdAt) AS startedAt,
|
|
470
|
+
MAX(createdAt) AS endedAt,
|
|
471
|
+
COUNT(*) AS events,
|
|
472
|
+
SUM(type='pageview') AS pageviews,
|
|
473
|
+
SUM(timeOnPageMs) AS timeMs,
|
|
474
|
+
MIN(CASE WHEN type='pageview' THEN url END) AS entryUrl
|
|
475
|
+
FROM visitor_event
|
|
476
|
+
WHERE visitorId = ?
|
|
477
|
+
GROUP BY sessionId
|
|
478
|
+
ORDER BY MIN(createdAt) DESC
|
|
479
|
+
LIMIT 50`, [visitorId]);
|
|
480
|
+
let customer = null;
|
|
481
|
+
if (latest.customerId) {
|
|
482
|
+
const [c] = await this.connection.rawConnection.query(`SELECT id, firstName, lastName, emailAddress
|
|
483
|
+
FROM customer WHERE id = ? LIMIT 1`, [Number(latest.customerId)]);
|
|
484
|
+
customer = c || null;
|
|
485
|
+
}
|
|
486
|
+
return res.json({
|
|
487
|
+
visitor: {
|
|
488
|
+
visitorId: latest.visitorId,
|
|
489
|
+
customerId: latest.customerId,
|
|
490
|
+
customer,
|
|
491
|
+
firstSeenAt: latest.firstSeenAt,
|
|
492
|
+
lastSeenAt: latest.lastSeenAt,
|
|
493
|
+
totals: {
|
|
494
|
+
sessions: Number(latest.totalSessions) || 0,
|
|
495
|
+
pageviews: Number(latest.totalPageviews) || 0,
|
|
496
|
+
unloads: Number(latest.totalUnloads) || 0,
|
|
497
|
+
events: Number(latest.totalEvents) || 0,
|
|
498
|
+
timeMs: Number(latest.totalTimeMs) || 0,
|
|
499
|
+
},
|
|
500
|
+
ip: latest.ip,
|
|
501
|
+
ipHash: latest.ipHash,
|
|
502
|
+
userAgent: latest.userAgent,
|
|
503
|
+
browser: latest.browser,
|
|
504
|
+
browserVersion: latest.browserVersion,
|
|
505
|
+
os: latest.os,
|
|
506
|
+
osVersion: latest.osVersion,
|
|
507
|
+
device: latest.device,
|
|
508
|
+
acceptLanguage: latest.acceptLanguage,
|
|
509
|
+
country: latest.country,
|
|
510
|
+
region: latest.region,
|
|
511
|
+
city: latest.city,
|
|
512
|
+
timezone: latest.timezone,
|
|
513
|
+
channelId: latest.channelId,
|
|
514
|
+
},
|
|
515
|
+
sessions: sessions.map((s) => ({
|
|
516
|
+
sessionId: s.sessionId,
|
|
517
|
+
startedAt: s.startedAt,
|
|
518
|
+
endedAt: s.endedAt,
|
|
519
|
+
events: Number(s.events) || 0,
|
|
520
|
+
pageviews: Number(s.pageviews) || 0,
|
|
521
|
+
timeMs: Number(s.timeMs) || 0,
|
|
522
|
+
entryUrl: s.entryUrl,
|
|
523
|
+
})),
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
// ------------------------------------------------------------------
|
|
527
|
+
applyCors(req, res) {
|
|
528
|
+
const origin = String(req.headers.origin || '');
|
|
529
|
+
// Allow the two storefronts + local dev. Anything else gets a
|
|
530
|
+
// wildcard echo of the request origin so previews work without
|
|
531
|
+
// needing config — we trust the request itself for ingest.
|
|
532
|
+
res.setHeader('Access-Control-Allow-Origin', origin || '*');
|
|
533
|
+
res.setHeader('Vary', 'Origin');
|
|
534
|
+
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
535
|
+
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
|
536
|
+
res.setHeader('Access-Control-Allow-Headers', 'content-type');
|
|
537
|
+
}
|
|
538
|
+
parseCookies(req) {
|
|
539
|
+
const raw = req.headers.cookie || '';
|
|
540
|
+
const out = {};
|
|
541
|
+
for (const part of raw.split(';')) {
|
|
542
|
+
const idx = part.indexOf('=');
|
|
543
|
+
if (idx < 0)
|
|
544
|
+
continue;
|
|
545
|
+
const k = part.slice(0, idx).trim();
|
|
546
|
+
const v = decodeURIComponent(part.slice(idx + 1).trim());
|
|
547
|
+
if (k)
|
|
548
|
+
out[k] = v;
|
|
549
|
+
}
|
|
550
|
+
return out;
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
exports.VisitorTrackingController = VisitorTrackingController;
|
|
554
|
+
__decorate([
|
|
555
|
+
(0, common_1.Post)('track'),
|
|
556
|
+
__param(0, (0, common_1.Body)()),
|
|
557
|
+
__param(1, (0, common_1.Req)()),
|
|
558
|
+
__param(2, (0, common_1.Res)()),
|
|
559
|
+
__metadata("design:type", Function),
|
|
560
|
+
__metadata("design:paramtypes", [Object, Object, Object]),
|
|
561
|
+
__metadata("design:returntype", Promise)
|
|
562
|
+
], VisitorTrackingController.prototype, "track", null);
|
|
563
|
+
__decorate([
|
|
564
|
+
(0, common_1.Get)('visitors/summary'),
|
|
565
|
+
__param(0, (0, core_1.Ctx)()),
|
|
566
|
+
__param(1, (0, common_1.Req)()),
|
|
567
|
+
__param(2, (0, common_1.Res)()),
|
|
568
|
+
__metadata("design:type", Function),
|
|
569
|
+
__metadata("design:paramtypes", [core_1.RequestContext, Object, Object]),
|
|
570
|
+
__metadata("design:returntype", Promise)
|
|
571
|
+
], VisitorTrackingController.prototype, "summary", null);
|
|
572
|
+
__decorate([
|
|
573
|
+
(0, common_1.Get)('visitors/sources'),
|
|
574
|
+
__param(0, (0, core_1.Ctx)()),
|
|
575
|
+
__param(1, (0, common_1.Req)()),
|
|
576
|
+
__param(2, (0, common_1.Res)()),
|
|
577
|
+
__metadata("design:type", Function),
|
|
578
|
+
__metadata("design:paramtypes", [core_1.RequestContext, Object, Object]),
|
|
579
|
+
__metadata("design:returntype", Promise)
|
|
580
|
+
], VisitorTrackingController.prototype, "sources", null);
|
|
581
|
+
__decorate([
|
|
582
|
+
(0, common_1.Get)('visitors/top-pages'),
|
|
583
|
+
__param(0, (0, core_1.Ctx)()),
|
|
584
|
+
__param(1, (0, common_1.Req)()),
|
|
585
|
+
__param(2, (0, common_1.Res)()),
|
|
586
|
+
__metadata("design:type", Function),
|
|
587
|
+
__metadata("design:paramtypes", [core_1.RequestContext, Object, Object]),
|
|
588
|
+
__metadata("design:returntype", Promise)
|
|
589
|
+
], VisitorTrackingController.prototype, "topPages", null);
|
|
590
|
+
__decorate([
|
|
591
|
+
(0, common_1.Get)('visitors/funnel'),
|
|
592
|
+
__param(0, (0, core_1.Ctx)()),
|
|
593
|
+
__param(1, (0, common_1.Req)()),
|
|
594
|
+
__param(2, (0, common_1.Res)()),
|
|
595
|
+
__metadata("design:type", Function),
|
|
596
|
+
__metadata("design:paramtypes", [core_1.RequestContext, Object, Object]),
|
|
597
|
+
__metadata("design:returntype", Promise)
|
|
598
|
+
], VisitorTrackingController.prototype, "funnel", null);
|
|
599
|
+
__decorate([
|
|
600
|
+
(0, common_1.Get)('visitors/exit-pages'),
|
|
601
|
+
__param(0, (0, core_1.Ctx)()),
|
|
602
|
+
__param(1, (0, common_1.Req)()),
|
|
603
|
+
__param(2, (0, common_1.Res)()),
|
|
604
|
+
__metadata("design:type", Function),
|
|
605
|
+
__metadata("design:paramtypes", [core_1.RequestContext, Object, Object]),
|
|
606
|
+
__metadata("design:returntype", Promise)
|
|
607
|
+
], VisitorTrackingController.prototype, "exitPages", null);
|
|
608
|
+
__decorate([
|
|
609
|
+
(0, common_1.Get)('visitors/top-events'),
|
|
610
|
+
__param(0, (0, core_1.Ctx)()),
|
|
611
|
+
__param(1, (0, common_1.Req)()),
|
|
612
|
+
__param(2, (0, common_1.Res)()),
|
|
613
|
+
__metadata("design:type", Function),
|
|
614
|
+
__metadata("design:paramtypes", [core_1.RequestContext, Object, Object]),
|
|
615
|
+
__metadata("design:returntype", Promise)
|
|
616
|
+
], VisitorTrackingController.prototype, "topEvents", null);
|
|
617
|
+
__decorate([
|
|
618
|
+
(0, common_1.Get)('visitors/live'),
|
|
619
|
+
__param(0, (0, core_1.Ctx)()),
|
|
620
|
+
__param(1, (0, common_1.Req)()),
|
|
621
|
+
__param(2, (0, common_1.Res)()),
|
|
622
|
+
__metadata("design:type", Function),
|
|
623
|
+
__metadata("design:paramtypes", [core_1.RequestContext, Object, Object]),
|
|
624
|
+
__metadata("design:returntype", Promise)
|
|
625
|
+
], VisitorTrackingController.prototype, "live", null);
|
|
626
|
+
__decorate([
|
|
627
|
+
(0, common_1.Get)('visitors/journey/:visitorId'),
|
|
628
|
+
__param(0, (0, core_1.Ctx)()),
|
|
629
|
+
__param(1, (0, common_1.Param)('visitorId')),
|
|
630
|
+
__param(2, (0, common_1.Res)()),
|
|
631
|
+
__metadata("design:type", Function),
|
|
632
|
+
__metadata("design:paramtypes", [core_1.RequestContext, String, Object]),
|
|
633
|
+
__metadata("design:returntype", Promise)
|
|
634
|
+
], VisitorTrackingController.prototype, "journey", null);
|
|
635
|
+
__decorate([
|
|
636
|
+
(0, common_1.Get)('visitors/recent'),
|
|
637
|
+
__param(0, (0, core_1.Ctx)()),
|
|
638
|
+
__param(1, (0, common_1.Req)()),
|
|
639
|
+
__param(2, (0, common_1.Res)()),
|
|
640
|
+
__metadata("design:type", Function),
|
|
641
|
+
__metadata("design:paramtypes", [core_1.RequestContext, Object, Object]),
|
|
642
|
+
__metadata("design:returntype", Promise)
|
|
643
|
+
], VisitorTrackingController.prototype, "recent", null);
|
|
644
|
+
__decorate([
|
|
645
|
+
(0, common_1.Get)('visitors/profile/:visitorId'),
|
|
646
|
+
__param(0, (0, core_1.Ctx)()),
|
|
647
|
+
__param(1, (0, common_1.Param)('visitorId')),
|
|
648
|
+
__param(2, (0, common_1.Res)()),
|
|
649
|
+
__metadata("design:type", Function),
|
|
650
|
+
__metadata("design:paramtypes", [core_1.RequestContext, String, Object]),
|
|
651
|
+
__metadata("design:returntype", Promise)
|
|
652
|
+
], VisitorTrackingController.prototype, "profile", null);
|
|
653
|
+
exports.VisitorTrackingController = VisitorTrackingController = __decorate([
|
|
654
|
+
(0, common_1.Controller)('ees'),
|
|
655
|
+
__metadata("design:paramtypes", [core_1.TransactionalConnection,
|
|
656
|
+
visitor_tracking_service_1.VisitorTrackingService])
|
|
657
|
+
], VisitorTrackingController);
|
|
658
|
+
//# sourceMappingURL=visitor-tracking.controller.js.map
|