@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.
@@ -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