@huloglobal/vendure-plugin-visitor-analytics 0.18.2 → 0.18.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +20 -8
  3. package/dist/abandoned-cart.controller.d.ts.map +1 -1
  4. package/dist/abandoned-cart.controller.js +55 -38
  5. package/dist/abandoned-cart.controller.js.map +1 -1
  6. package/dist/abandoned-cart.scanner.d.ts +4 -0
  7. package/dist/abandoned-cart.scanner.d.ts.map +1 -1
  8. package/dist/abandoned-cart.scanner.js +28 -0
  9. package/dist/abandoned-cart.scanner.js.map +1 -1
  10. package/dist/abandoned-cart.service.d.ts +9 -0
  11. package/dist/abandoned-cart.service.d.ts.map +1 -1
  12. package/dist/abandoned-cart.service.js +125 -84
  13. package/dist/abandoned-cart.service.js.map +1 -1
  14. package/dist/admin-api.js +22 -22
  15. package/dist/admin-api.js.map +1 -1
  16. package/dist/ingest-hardening.test.js +6 -6
  17. package/dist/ingest-hardening.test.js.map +1 -1
  18. package/dist/ingest-utils.d.ts +6 -0
  19. package/dist/ingest-utils.d.ts.map +1 -0
  20. package/dist/ingest-utils.js +34 -0
  21. package/dist/ingest-utils.js.map +1 -0
  22. package/dist/journey-buffs.service.js +21 -21
  23. package/dist/recommendations.controller.d.ts +10 -4
  24. package/dist/recommendations.controller.d.ts.map +1 -1
  25. package/dist/recommendations.controller.js +9 -6
  26. package/dist/recommendations.controller.js.map +1 -1
  27. package/dist/recommendations.service.d.ts +28 -8
  28. package/dist/recommendations.service.d.ts.map +1 -1
  29. package/dist/recommendations.service.js +164 -78
  30. package/dist/recommendations.service.js.map +1 -1
  31. package/dist/search-analytics.service.d.ts.map +1 -1
  32. package/dist/search-analytics.service.js +22 -18
  33. package/dist/search-analytics.service.js.map +1 -1
  34. package/dist/sql-fragments.d.ts +25 -0
  35. package/dist/sql-fragments.d.ts.map +1 -0
  36. package/dist/sql-fragments.js +31 -0
  37. package/dist/sql-fragments.js.map +1 -0
  38. package/dist/visitor-tracking.controller.d.ts +8 -1
  39. package/dist/visitor-tracking.controller.d.ts.map +1 -1
  40. package/dist/visitor-tracking.controller.js +219 -134
  41. package/dist/visitor-tracking.controller.js.map +1 -1
  42. package/dist/visitor-tracking.service.d.ts +0 -4
  43. package/dist/visitor-tracking.service.d.ts.map +1 -1
  44. package/dist/visitor-tracking.service.js +3 -32
  45. package/dist/visitor-tracking.service.js.map +1 -1
  46. package/package.json +1 -1
@@ -13,6 +13,7 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
13
13
  };
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.VisitorTrackingController = void 0;
16
+ exports.drained = drained;
16
17
  const common_1 = require("@nestjs/common");
17
18
  const vendure_licence_sdk_1 = require("@huloglobal/vendure-licence-sdk");
18
19
  const core_1 = require("@vendure/core");
@@ -64,6 +65,14 @@ function requireAdmin(ctx, res, perms = [core_1.Permission.ReadCustomer]) {
64
65
  }
65
66
  return true;
66
67
  }
68
+ /** Back-pressure for streamed responses: resolves on 'drain' or when the client goes away. */
69
+ function drained(res) {
70
+ return new Promise(resolve => {
71
+ const done = () => { res.off('drain', done); res.off('close', done); resolve(); };
72
+ res.once('drain', done);
73
+ res.once('close', done);
74
+ });
75
+ }
67
76
  function clampInt(raw, fallback, min, max) {
68
77
  const n = parseInt(String(raw !== null && raw !== void 0 ? raw : fallback), 10);
69
78
  if (isNaN(n))
@@ -106,7 +115,8 @@ function parseChannelId(raw) {
106
115
  function channelWhere(channelId, alias) {
107
116
  if (channelId == null)
108
117
  return { sql: '', params: [] };
109
- const col = alias ? `${alias}.channelId` : 'channelId';
118
+ // Backticked: `channelId` is a quoted camelCase column on Postgres.
119
+ const col = alias ? `${alias}.\`channelId\`` : '`channelId`';
110
120
  return { sql: `AND ${col} = ?`, params: [channelId] };
111
121
  }
112
122
  const proxy_headers_1 = require("./proxy-headers");
@@ -140,6 +150,9 @@ let VisitorTrackingController = class VisitorTrackingController {
140
150
  this.licenceStore = new vendure_licence_sdk_1.LicenceStore((sql, params) => (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(sql, params));
141
151
  /** Buy-from-admin auto-install client. */
142
152
  this.purchaseClaim = null;
153
+ // ------------------------------------------------------------------
154
+ /** userId → customerId, cached 10 min (bounded). Null when the request carries no session user. */
155
+ this.customerByUser = new Map();
143
156
  }
144
157
  onApplicationBootstrap() {
145
158
  void this.purchaseClaimClient().resume();
@@ -293,14 +306,18 @@ let VisitorTrackingController = class VisitorTrackingController {
293
306
  }
294
307
  return this.purchaseClaim;
295
308
  }
296
- async track(body, req, res) {
309
+ /** CORS preflight for the beacon (a JSON `fetch` from another origin sends one). */
310
+ trackPreflight(req, res) {
311
+ this.applyCors(req, res);
312
+ res.setHeader('Access-Control-Max-Age', '600');
313
+ return res.status(204).end();
314
+ }
315
+ async track(ctx, body, req, res) {
297
316
  var _a;
298
317
  // Lenient CORS — analytics ingestion must work cross-origin from
299
318
  // both storefronts (and dev hosts) without complex config.
300
319
  this.applyCors(req, res);
301
320
  (0, vendure_licence_sdk_1.applySecurityHeaders)(res);
302
- if (req.method === 'OPTIONS')
303
- return res.status(204).end();
304
321
  if (this.rateLimited(req, res, 'track'))
305
322
  return;
306
323
  // Tier-gate: unlicensed installs are capped at 100 events / UTC day.
@@ -361,7 +378,10 @@ let VisitorTrackingController = class VisitorTrackingController {
361
378
  issuedSession = true;
362
379
  }
363
380
  const channelId = clampInt(body === null || body === void 0 ? void 0 : body.channelId, 1, 1, 2147483647);
364
- const customerId = (body === null || body === void 0 ? void 0 : body.customerId) != null ? Number(body.customerId) || null : null;
381
+ // The customer comes from the Vendure session, never from the body: an
382
+ // anonymous beacon used to be able to file events under any customer id.
383
+ // The body value is honoured only when it matches the session's customer.
384
+ const customerId = await this.resolveCustomerId(ctx);
365
385
  const events = Array.isArray(body === null || body === void 0 ? void 0 : body.events) ? body.events.slice(0, 50) : [];
366
386
  const ip = realIp(req);
367
387
  // Prefer the upstream proxy's resolved country / region (Cloudflare,
@@ -405,30 +425,30 @@ let VisitorTrackingController = class VisitorTrackingController {
405
425
  const days = Math.min(Math.max(parseInt(String(req.query.days || '30'), 10) || 30, 1), 365);
406
426
  const channelId = parseChannelId(req.query.channelId);
407
427
  const w = channelWhere(channelId);
408
- const rows = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT DATE(createdAt) AS day,
409
- COUNT(DISTINCT visitorId) AS visitors,
410
- COUNT(DISTINCT sessionId) AS sessions,
428
+ const rows = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT DATE(\`createdAt\`) AS day,
429
+ COUNT(DISTINCT \`visitorId\`) AS visitors,
430
+ COUNT(DISTINCT \`sessionId\`) AS sessions,
411
431
  COUNT(*) AS events,
412
432
  SUM(CASE WHEN type='pageview' THEN 1 ELSE 0 END) AS pageviews
413
433
  FROM visitor_event
414
- WHERE createdAt >= DATE_SUB(NOW(), INTERVAL ? DAY) ${w.sql}
415
- GROUP BY DATE(createdAt)
434
+ WHERE \`createdAt\` >= DATE_SUB(NOW(), INTERVAL ? DAY) ${w.sql}
435
+ GROUP BY DATE(\`createdAt\`)
416
436
  ORDER BY day`, [days, ...w.params]);
417
- const [{ totalVisitors, totalSessions, totalPageviews, avgTimeMs }] = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT COUNT(DISTINCT visitorId) AS totalVisitors,
418
- COUNT(DISTINCT sessionId) AS totalSessions,
419
- SUM(CASE WHEN type='pageview' THEN 1 ELSE 0 END) AS totalPageviews,
420
- AVG(CASE WHEN type='unload' AND timeOnPageMs > 0 THEN timeOnPageMs END) AS avgTimeMs
437
+ const [{ totalVisitors, totalSessions, totalPageviews, avgTimeMs }] = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT COUNT(DISTINCT \`visitorId\`) AS \`totalVisitors\`,
438
+ COUNT(DISTINCT \`sessionId\`) AS \`totalSessions\`,
439
+ SUM(CASE WHEN type='pageview' THEN 1 ELSE 0 END) AS \`totalPageviews\`,
440
+ AVG(CASE WHEN type='unload' AND \`timeOnPageMs\` > 0 THEN \`timeOnPageMs\` END) AS \`avgTimeMs\`
421
441
  FROM visitor_event
422
- WHERE createdAt >= DATE_SUB(NOW(), INTERVAL ? DAY) ${w.sql}`, [days, ...w.params]);
442
+ WHERE \`createdAt\` >= DATE_SUB(NOW(), INTERVAL ? DAY) ${w.sql}`, [days, ...w.params]);
423
443
  // Previous period of identical length, immediately prior —
424
444
  // powers the KPI delta chips ("+12% vs previous 30 days").
425
- const [prev] = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT COUNT(DISTINCT visitorId) AS totalVisitors,
426
- COUNT(DISTINCT sessionId) AS totalSessions,
427
- SUM(CASE WHEN type='pageview' THEN 1 ELSE 0 END) AS totalPageviews,
428
- AVG(CASE WHEN type='unload' AND timeOnPageMs > 0 THEN timeOnPageMs END) AS avgTimeMs
445
+ const [prev] = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT COUNT(DISTINCT \`visitorId\`) AS \`totalVisitors\`,
446
+ COUNT(DISTINCT \`sessionId\`) AS \`totalSessions\`,
447
+ SUM(CASE WHEN type='pageview' THEN 1 ELSE 0 END) AS \`totalPageviews\`,
448
+ AVG(CASE WHEN type='unload' AND \`timeOnPageMs\` > 0 THEN \`timeOnPageMs\` END) AS \`avgTimeMs\`
429
449
  FROM visitor_event
430
- WHERE createdAt >= DATE_SUB(NOW(), INTERVAL ? DAY)
431
- AND createdAt < DATE_SUB(NOW(), INTERVAL ? DAY) ${w.sql}`, [days * 2, days, ...w.params]);
450
+ WHERE \`createdAt\` >= DATE_SUB(NOW(), INTERVAL ? DAY)
451
+ AND \`createdAt\` < DATE_SUB(NOW(), INTERVAL ? DAY) ${w.sql}`, [days * 2, days, ...w.params]);
432
452
  return res.json({
433
453
  days, channelId,
434
454
  totals: {
@@ -464,33 +484,33 @@ let VisitorTrackingController = class VisitorTrackingController {
464
484
  const channelId = parseChannelId(req.query.channelId);
465
485
  const w = channelWhere(channelId);
466
486
  const rows = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT source, medium, COALESCE(MAX(campaign), '') AS campaign,
467
- COUNT(DISTINCT visitorId) AS visitors,
468
- COUNT(DISTINCT sessionId) AS sessions,
469
- SUM(reached_product) AS productViewers,
470
- SUM(reached_checkout) AS checkoutReached
487
+ COUNT(DISTINCT \`visitorId\`) AS visitors,
488
+ COUNT(DISTINCT \`sessionId\`) AS sessions,
489
+ SUM(reached_product) AS \`productViewers\`,
490
+ SUM(reached_checkout) AS \`checkoutReached\`
471
491
  FROM (
472
- SELECT visitorId, sessionId,
473
- COALESCE(utmSource, referrerDomain, '(direct)') AS source,
474
- COALESCE(utmMedium, IF(referrerDomain IS NOT NULL, 'referral', 'none')) AS medium,
475
- utmCampaign AS campaign,
492
+ SELECT \`visitorId\`, \`sessionId\`,
493
+ COALESCE(\`utmSource\`, \`referrerDomain\`, '(direct)') AS source,
494
+ COALESCE(\`utmMedium\`, IF(\`referrerDomain\` IS NOT NULL, 'referral', 'none')) AS medium,
495
+ \`utmCampaign\` AS campaign,
476
496
  MAX(CASE WHEN url LIKE '/products/%' AND type='pageview' THEN 1 ELSE 0 END) AS reached_product,
477
497
  MAX(CASE WHEN (url LIKE '%/checkout%' OR url LIKE '%cart%') AND type='pageview' THEN 1 ELSE 0 END) AS reached_checkout
478
498
  FROM visitor_event
479
- WHERE createdAt >= DATE_SUB(NOW(), INTERVAL ? DAY) ${w.sql}
480
- GROUP BY visitorId, sessionId,
481
- COALESCE(utmSource, referrerDomain, '(direct)'),
482
- COALESCE(utmMedium, IF(referrerDomain IS NOT NULL, 'referral', 'none')),
483
- utmCampaign
499
+ WHERE \`createdAt\` >= DATE_SUB(NOW(), INTERVAL ? DAY) ${w.sql}
500
+ GROUP BY \`visitorId\`, \`sessionId\`,
501
+ COALESCE(\`utmSource\`, \`referrerDomain\`, '(direct)'),
502
+ COALESCE(\`utmMedium\`, IF(\`referrerDomain\` IS NOT NULL, 'referral', 'none')),
503
+ \`utmCampaign\`
484
504
  ) by_session
485
505
  GROUP BY source, medium
486
506
  ORDER BY visitors DESC
487
507
  LIMIT ? OFFSET ?`, [days, ...w.params, take, skip]);
488
508
  const [{ total }] = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT COUNT(*) AS total FROM (
489
509
  SELECT DISTINCT
490
- COALESCE(utmSource, referrerDomain, '(direct)') AS source,
491
- COALESCE(utmMedium, IF(referrerDomain IS NOT NULL, 'referral', 'none')) AS medium
510
+ COALESCE(\`utmSource\`, \`referrerDomain\`, '(direct)') AS source,
511
+ COALESCE(\`utmMedium\`, IF(\`referrerDomain\` IS NOT NULL, 'referral', 'none')) AS medium
492
512
  FROM visitor_event
493
- WHERE createdAt >= DATE_SUB(NOW(), INTERVAL ? DAY) ${w.sql}
513
+ WHERE \`createdAt\` >= DATE_SUB(NOW(), INTERVAL ? DAY) ${w.sql}
494
514
  ) sources`, [days, ...w.params]);
495
515
  return res.json({
496
516
  sources: rows.map((r) => ({
@@ -517,16 +537,16 @@ let VisitorTrackingController = class VisitorTrackingController {
517
537
  const rows = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT url,
518
538
  MAX(title) AS title,
519
539
  COUNT(*) AS views,
520
- COUNT(DISTINCT visitorId) AS uniqueVisitors,
521
- AVG(NULLIF(timeOnPageMs, 0)) AS avgTimeMs
540
+ COUNT(DISTINCT \`visitorId\`) AS \`uniqueVisitors\`,
541
+ AVG(NULLIF(\`timeOnPageMs\`, 0)) AS \`avgTimeMs\`
522
542
  FROM visitor_event
523
- WHERE createdAt >= DATE_SUB(NOW(), INTERVAL ? DAY) ${w.sql}
543
+ WHERE \`createdAt\` >= DATE_SUB(NOW(), INTERVAL ? DAY) ${w.sql}
524
544
  AND type IN ('pageview', 'unload')
525
545
  GROUP BY url
526
546
  ORDER BY views DESC
527
547
  LIMIT ? OFFSET ?`, [days, ...w.params, take, skip]);
528
548
  const [{ total }] = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT COUNT(DISTINCT url) AS total FROM visitor_event
529
- WHERE createdAt >= DATE_SUB(NOW(), INTERVAL ? DAY) ${w.sql}
549
+ WHERE \`createdAt\` >= DATE_SUB(NOW(), INTERVAL ? DAY) ${w.sql}
530
550
  AND type IN ('pageview', 'unload')`, [days, ...w.params]);
531
551
  return res.json({
532
552
  pages: rows.map((r) => ({
@@ -553,8 +573,8 @@ let VisitorTrackingController = class VisitorTrackingController {
553
573
  ];
554
574
  const result = [];
555
575
  for (const s of stages) {
556
- const [{ n }] = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT COUNT(DISTINCT visitorId) AS n FROM visitor_event
557
- WHERE createdAt >= DATE_SUB(NOW(), INTERVAL ? DAY) ${w.sql}
576
+ const [{ n }] = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT COUNT(DISTINCT \`visitorId\`) AS n FROM visitor_event
577
+ WHERE \`createdAt\` >= DATE_SUB(NOW(), INTERVAL ? DAY) ${w.sql}
558
578
  AND type='pageview' AND (${s.where})`, [days, ...w.params]);
559
579
  result.push({ key: s.key, label: s.label, visitors: Number(n) || 0 });
560
580
  }
@@ -574,10 +594,10 @@ let VisitorTrackingController = class VisitorTrackingController {
574
594
  MAX(title) AS title,
575
595
  COUNT(*) AS exits
576
596
  FROM (
577
- SELECT sessionId, url, title, createdAt,
578
- ROW_NUMBER() OVER (PARTITION BY sessionId ORDER BY createdAt DESC) AS rn
597
+ SELECT \`sessionId\`, url, title, \`createdAt\`,
598
+ ROW_NUMBER() OVER (PARTITION BY \`sessionId\` ORDER BY \`createdAt\` DESC) AS rn
579
599
  FROM visitor_event
580
- WHERE createdAt >= DATE_SUB(NOW(), INTERVAL ? DAY) ${w.sql}
600
+ WHERE \`createdAt\` >= DATE_SUB(NOW(), INTERVAL ? DAY) ${w.sql}
581
601
  AND type='pageview'
582
602
  ) last_pages
583
603
  WHERE rn = 1
@@ -585,10 +605,10 @@ let VisitorTrackingController = class VisitorTrackingController {
585
605
  ORDER BY exits DESC
586
606
  LIMIT ? OFFSET ?`, [days, ...w.params, take, skip]);
587
607
  const [{ total }] = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT COUNT(DISTINCT url) AS total FROM (
588
- SELECT sessionId, url,
589
- ROW_NUMBER() OVER (PARTITION BY sessionId ORDER BY createdAt DESC) AS rn
608
+ SELECT \`sessionId\`, url,
609
+ ROW_NUMBER() OVER (PARTITION BY \`sessionId\` ORDER BY \`createdAt\` DESC) AS rn
590
610
  FROM visitor_event
591
- WHERE createdAt >= DATE_SUB(NOW(), INTERVAL ? DAY) ${w.sql}
611
+ WHERE \`createdAt\` >= DATE_SUB(NOW(), INTERVAL ? DAY) ${w.sql}
592
612
  AND type='pageview'
593
613
  ) lp WHERE rn = 1`, [days, ...w.params]);
594
614
  return res.json({
@@ -611,16 +631,16 @@ let VisitorTrackingController = class VisitorTrackingController {
611
631
  const w = channelWhere(channelId);
612
632
  const rows = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT type,
613
633
  COUNT(*) AS count,
614
- COUNT(DISTINCT visitorId) AS uniqueVisitors,
615
- COUNT(DISTINCT sessionId) AS sessions
634
+ COUNT(DISTINCT \`visitorId\`) AS \`uniqueVisitors\`,
635
+ COUNT(DISTINCT \`sessionId\`) AS sessions
616
636
  FROM visitor_event
617
- WHERE createdAt >= DATE_SUB(NOW(), INTERVAL ? DAY) ${w.sql}
637
+ WHERE \`createdAt\` >= DATE_SUB(NOW(), INTERVAL ? DAY) ${w.sql}
618
638
  AND type NOT IN ('pageview', 'unload')
619
639
  GROUP BY type
620
640
  ORDER BY count DESC
621
641
  LIMIT ? OFFSET ?`, [days, ...w.params, take, skip]);
622
642
  const [{ total }] = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT COUNT(DISTINCT type) AS total FROM visitor_event
623
- WHERE createdAt >= DATE_SUB(NOW(), INTERVAL ? DAY) ${w.sql}
643
+ WHERE \`createdAt\` >= DATE_SUB(NOW(), INTERVAL ? DAY) ${w.sql}
624
644
  AND type NOT IN ('pageview', 'unload')`, [days, ...w.params]);
625
645
  return res.json({
626
646
  events: rows.map((r) => ({
@@ -672,9 +692,9 @@ let VisitorTrackingController = class VisitorTrackingController {
672
692
  const days = clampInt(req.query.days, 30, 1, 365);
673
693
  const channelId = parseChannelId(req.query.channelId);
674
694
  const w = channelWhere(channelId);
675
- const grouped = (col) => (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT ${col} AS label, COUNT(DISTINCT visitorId) AS visitors
695
+ const grouped = (col) => (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT ${col} AS label, COUNT(DISTINCT \`visitorId\`) AS visitors
676
696
  FROM visitor_event
677
- WHERE createdAt >= DATE_SUB(NOW(), INTERVAL ? DAY) ${w.sql}
697
+ WHERE \`createdAt\` >= DATE_SUB(NOW(), INTERVAL ? DAY) ${w.sql}
678
698
  AND ${col} IS NOT NULL AND ${col} <> ''
679
699
  GROUP BY ${col}
680
700
  ORDER BY visitors DESC
@@ -706,18 +726,28 @@ let VisitorTrackingController = class VisitorTrackingController {
706
726
  res.setHeader('Connection', 'keep-alive');
707
727
  res.setHeader('X-Accel-Buffering', 'no'); // disable nginx buffering
708
728
  (_a = res.flushHeaders) === null || _a === void 0 ? void 0 : _a.call(res);
729
+ // One query in flight at a time: a slow tick used to pile up behind
730
+ // itself every 5 s. A `: keepalive` comment every 15 s keeps proxies
731
+ // from closing an idle stream between data frames.
732
+ let inFlight = false;
733
+ let closed = false;
709
734
  const tick = async () => {
735
+ if (inFlight || closed)
736
+ return;
737
+ inFlight = true;
710
738
  try {
711
- const rows = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT visitorId,
739
+ const rows = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT \`visitorId\`,
712
740
  MAX(url) AS url,
713
741
  MAX(country) AS country,
714
- TIMESTAMPDIFF(SECOND, MAX(createdAt), NOW()) AS secondsAgo
742
+ TIMESTAMPDIFF(SECOND, MAX(\`createdAt\`), NOW()) AS \`secondsAgo\`
715
743
  FROM visitor_event
716
- WHERE createdAt >= DATE_SUB(NOW(), INTERVAL 5 MINUTE) ${w.sql}
744
+ WHERE \`createdAt\` >= DATE_SUB(NOW(), INTERVAL 5 MINUTE) ${w.sql}
717
745
  AND type = 'pageview'
718
- GROUP BY visitorId
719
- ORDER BY MAX(createdAt) DESC
746
+ GROUP BY \`visitorId\`
747
+ ORDER BY MAX(\`createdAt\`) DESC
720
748
  LIMIT 20`, [...w.params]);
749
+ if (closed)
750
+ return;
721
751
  const payload = {
722
752
  ts: new Date().toISOString(),
723
753
  activeCount: rows.length,
@@ -735,12 +765,18 @@ let VisitorTrackingController = class VisitorTrackingController {
735
765
  // tick will retry. SSE clients reconnect automatically
736
766
  // if the connection drops.
737
767
  }
768
+ finally {
769
+ inFlight = false;
770
+ }
738
771
  };
739
772
  // Fire immediately, then every 5s. Stop when the client disconnects.
740
773
  await tick();
741
774
  const interval = setInterval(() => { tick().catch(() => undefined); }, 5000);
742
- req.on('close', () => clearInterval(interval));
743
- req.on('end', () => clearInterval(interval));
775
+ const keepalive = setInterval(() => { if (!closed)
776
+ res.write(': keepalive\n\n'); }, 15000);
777
+ const stop = () => { closed = true; clearInterval(interval); clearInterval(keepalive); };
778
+ req.on('close', stop);
779
+ req.on('end', stop);
744
780
  }
745
781
  /** Journey timeline for one visitor — every event in order. */
746
782
  async journey(ctx, visitorId, res) {
@@ -763,11 +799,11 @@ let VisitorTrackingController = class VisitorTrackingController {
763
799
  const skip = clampInt(req.query.skip, 0, 0, 1000000);
764
800
  const channelId = parseChannelId(req.query.channelId);
765
801
  const w = channelWhere(channelId);
766
- const rows = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT visitorId,
767
- MAX(customerId) AS customerId,
768
- MIN(createdAt) AS firstSeenAt,
769
- MAX(createdAt) AS lastSeenAt,
770
- COUNT(DISTINCT sessionId) AS sessions,
802
+ const rows = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT \`visitorId\`,
803
+ MAX(\`customerId\`) AS \`customerId\`,
804
+ MIN(\`createdAt\`) AS \`firstSeenAt\`,
805
+ MAX(\`createdAt\`) AS \`lastSeenAt\`,
806
+ COUNT(DISTINCT \`sessionId\`) AS sessions,
771
807
  SUM(CASE WHEN type='pageview' THEN 1 ELSE 0 END) AS pageviews,
772
808
  MAX(country) AS country,
773
809
  MAX(city) AS city,
@@ -775,12 +811,12 @@ let VisitorTrackingController = class VisitorTrackingController {
775
811
  MAX(os) AS os,
776
812
  MAX(device) AS device
777
813
  FROM visitor_event
778
- WHERE createdAt >= DATE_SUB(NOW(), INTERVAL ? DAY) ${w.sql}
779
- GROUP BY visitorId
780
- ORDER BY MAX(createdAt) DESC
814
+ WHERE \`createdAt\` >= DATE_SUB(NOW(), INTERVAL ? DAY) ${w.sql}
815
+ GROUP BY \`visitorId\`
816
+ ORDER BY MAX(\`createdAt\`) DESC
781
817
  LIMIT ? OFFSET ?`, [days, ...w.params, take, skip]);
782
- const [{ total }] = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT COUNT(DISTINCT visitorId) AS total
783
- FROM visitor_event WHERE createdAt >= DATE_SUB(NOW(), INTERVAL ? DAY) ${w.sql}
818
+ const [{ total }] = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT COUNT(DISTINCT \`visitorId\`) AS total
819
+ FROM visitor_event WHERE \`createdAt\` >= DATE_SUB(NOW(), INTERVAL ? DAY) ${w.sql}
784
820
  `, [days, ...w.params]);
785
821
  return res.json({
786
822
  visitors: rows.map((r) => ({
@@ -808,49 +844,49 @@ let VisitorTrackingController = class VisitorTrackingController {
808
844
  return;
809
845
  const visitorId = String(visitorIdRaw).slice(0, 64);
810
846
  // One row with the latest non-null value of every column.
811
- const [latest] = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT visitorId,
812
- MAX(customerId) AS customerId,
813
- MIN(createdAt) AS firstSeenAt,
814
- MAX(createdAt) AS lastSeenAt,
815
- COUNT(DISTINCT sessionId) AS totalSessions,
816
- SUM(CASE WHEN type='pageview' THEN 1 ELSE 0 END) AS totalPageviews,
817
- SUM(CASE WHEN type='unload' THEN 1 ELSE 0 END) AS totalUnloads,
818
- SUM(CASE WHEN type='event' THEN 1 ELSE 0 END) AS totalEvents,
819
- SUM(timeOnPageMs) AS totalTimeMs,
847
+ const [latest] = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT \`visitorId\`,
848
+ MAX(\`customerId\`) AS \`customerId\`,
849
+ MIN(\`createdAt\`) AS \`firstSeenAt\`,
850
+ MAX(\`createdAt\`) AS \`lastSeenAt\`,
851
+ COUNT(DISTINCT \`sessionId\`) AS \`totalSessions\`,
852
+ SUM(CASE WHEN type='pageview' THEN 1 ELSE 0 END) AS \`totalPageviews\`,
853
+ SUM(CASE WHEN type='unload' THEN 1 ELSE 0 END) AS \`totalUnloads\`,
854
+ SUM(CASE WHEN type='event' THEN 1 ELSE 0 END) AS \`totalEvents\`,
855
+ SUM(\`timeOnPageMs\`) AS \`totalTimeMs\`,
820
856
  MAX(ip) AS ip,
821
- MAX(ipHash) AS ipHash,
822
- MAX(userAgent) AS userAgent,
857
+ MAX(\`ipHash\`) AS \`ipHash\`,
858
+ MAX(\`userAgent\`) AS \`userAgent\`,
823
859
  MAX(browser) AS browser,
824
- MAX(browserVersion) AS browserVersion,
860
+ MAX(\`browserVersion\`) AS \`browserVersion\`,
825
861
  MAX(os) AS os,
826
- MAX(osVersion) AS osVersion,
862
+ MAX(\`osVersion\`) AS \`osVersion\`,
827
863
  MAX(device) AS device,
828
- MAX(acceptLanguage) AS acceptLanguage,
864
+ MAX(\`acceptLanguage\`) AS \`acceptLanguage\`,
829
865
  MAX(country) AS country,
830
866
  MAX(region) AS region,
831
867
  MAX(city) AS city,
832
868
  MAX(timezone) AS timezone,
833
- MAX(channelId) AS channelId
869
+ MAX(\`channelId\`) AS \`channelId\`
834
870
  FROM visitor_event
835
- WHERE visitorId = ?
836
- GROUP BY visitorId`, [visitorId]);
871
+ WHERE \`visitorId\` = ?
872
+ GROUP BY \`visitorId\``, [visitorId]);
837
873
  if (!latest)
838
874
  return res.status(404).json({ error: 'visitor not found' });
839
- const sessions = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT sessionId,
840
- MIN(createdAt) AS startedAt,
841
- MAX(createdAt) AS endedAt,
875
+ const sessions = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT \`sessionId\`,
876
+ MIN(\`createdAt\`) AS \`startedAt\`,
877
+ MAX(\`createdAt\`) AS \`endedAt\`,
842
878
  COUNT(*) AS events,
843
879
  SUM(CASE WHEN type='pageview' THEN 1 ELSE 0 END) AS pageviews,
844
- SUM(timeOnPageMs) AS timeMs,
845
- MIN(CASE WHEN type='pageview' THEN url END) AS entryUrl
880
+ SUM(\`timeOnPageMs\`) AS \`timeMs\`,
881
+ MIN(CASE WHEN type='pageview' THEN url END) AS \`entryUrl\`
846
882
  FROM visitor_event
847
- WHERE visitorId = ?
848
- GROUP BY sessionId
849
- ORDER BY MIN(createdAt) DESC
883
+ WHERE \`visitorId\` = ?
884
+ GROUP BY \`sessionId\`
885
+ ORDER BY MIN(\`createdAt\`) DESC
850
886
  LIMIT 50`, [visitorId]);
851
887
  let customer = null;
852
888
  if (latest.customerId) {
853
- const [c] = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT id, firstName, lastName, emailAddress
889
+ const [c] = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT id, \`firstName\`, \`lastName\`, \`emailAddress\`
854
890
  FROM customer WHERE id = ? LIMIT 1`, [Number(latest.customerId)]);
855
891
  customer = c || null;
856
892
  }
@@ -894,19 +930,42 @@ let VisitorTrackingController = class VisitorTrackingController {
894
930
  })),
895
931
  });
896
932
  }
897
- // ------------------------------------------------------------------
933
+ async resolveCustomerId(ctx) {
934
+ var _a;
935
+ const userId = ctx === null || ctx === void 0 ? void 0 : ctx.activeUserId;
936
+ if (!userId)
937
+ return null;
938
+ const key = String(userId);
939
+ const now = Date.now();
940
+ const hit = this.customerByUser.get(key);
941
+ if (hit && hit.exp > now)
942
+ return hit.id;
943
+ let id = null;
944
+ try {
945
+ const rows = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT id FROM customer WHERE \`userId\` = ? AND \`deletedAt\` IS NULL LIMIT 1`, [userId]);
946
+ id = ((_a = rows === null || rows === void 0 ? void 0 : rows[0]) === null || _a === void 0 ? void 0 : _a.id) != null ? Number(rows[0].id) || null : null;
947
+ }
948
+ catch {
949
+ id = null;
950
+ }
951
+ if (this.customerByUser.size > 5000)
952
+ this.customerByUser.clear();
953
+ this.customerByUser.set(key, { id, exp: now + 10 * 60000 });
954
+ return id;
955
+ }
898
956
  applyCors(req, res) {
899
957
  const origin = String(req.headers.origin || '');
900
958
  const allowList = (0, plugin_1.getOptions)().corsAllowedOrigins || [];
901
- // When an allowlist is configured we reflect only matching
902
- // origins; otherwise we reflect any (legacy behaviour, looser).
903
- let allow = origin || '*';
904
- if (allowList.length) {
905
- allow = allowList.includes(origin) ? origin : 'null';
906
- }
907
- res.setHeader('Access-Control-Allow-Origin', allow);
959
+ // When an allowlist is configured we reflect only matching origins;
960
+ // otherwise we reflect any (legacy behaviour, looser). A disallowed
961
+ // origin gets NO Allow-Origin header — `null` is itself a valid
962
+ // origin value (sandboxed iframes, file://) and would let it through.
908
963
  res.setHeader('Vary', 'Origin');
909
- res.setHeader('Access-Control-Allow-Credentials', 'true');
964
+ if (allowList.length && !allowList.includes(origin))
965
+ return;
966
+ res.setHeader('Access-Control-Allow-Origin', origin || '*');
967
+ if (origin)
968
+ res.setHeader('Access-Control-Allow-Credentials', 'true');
910
969
  res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
911
970
  res.setHeader('Access-Control-Allow-Headers', 'content-type');
912
971
  }
@@ -1027,15 +1086,15 @@ let VisitorTrackingController = class VisitorTrackingController {
1027
1086
  return;
1028
1087
  const days = Math.min(Math.max(parseInt(String(req.query.days || '30'), 10) || 30, 1), 365);
1029
1088
  const channelId = parseInt(String(req.query.channelId || '1'), 10) || 1;
1030
- const rows = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT g.id, g.name, g.urlPattern, g.valueMinor, g.enabled,
1031
- COUNT(DISTINCT v.visitorId) AS uniqueVisitors,
1089
+ const rows = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT g.id, g.name, g.\`urlPattern\`, g.\`valueMinor\`, g.enabled,
1090
+ COUNT(DISTINCT v.\`visitorId\`) AS \`uniqueVisitors\`,
1032
1091
  COUNT(v.id) AS completions
1033
1092
  FROM conversion_goal g
1034
1093
  LEFT JOIN visitor_event v
1035
- ON v.goalId = g.id
1036
- AND v.createdAt >= DATE_SUB(NOW(), INTERVAL ? DAY)
1037
- AND v.isBot = 0
1038
- WHERE g.channelId = ?
1094
+ ON v.\`goalId\` = g.id
1095
+ AND v.\`createdAt\` >= DATE_SUB(NOW(), INTERVAL ? DAY)
1096
+ AND v.\`isBot\` = FALSE
1097
+ WHERE g.\`channelId\` = ?
1039
1098
  GROUP BY g.id`, [days, channelId]);
1040
1099
  return res.json({ days, channelId, goals: rows.map((r) => ({
1041
1100
  ...r,
@@ -1052,14 +1111,6 @@ let VisitorTrackingController = class VisitorTrackingController {
1052
1111
  const days = Math.min(Math.max(parseInt(String(req.query.days || '7'), 10) || 7, 1), 90);
1053
1112
  const channelId = parseChannelId(req.query.channelId);
1054
1113
  const w = channelWhere(channelId);
1055
- const rows = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT createdAt, visitorId, sessionId, customerId, channelId,
1056
- type, url, title, referrerDomain,
1057
- country, region, city, browser, os, device,
1058
- isBot, goalId, utmSource, utmMedium, utmCampaign
1059
- FROM visitor_event
1060
- WHERE createdAt >= DATE_SUB(NOW(), INTERVAL ? DAY) ${w.sql}
1061
- ORDER BY createdAt DESC
1062
- LIMIT 200000`, [days, ...w.params]);
1063
1114
  res.setHeader('Content-Type', 'text/csv; charset=utf-8');
1064
1115
  res.setHeader('Content-Disposition', `attachment; filename="visitors-${new Date().toISOString().slice(0, 10)}.csv"`);
1065
1116
  const esc = (v) => {
@@ -1072,15 +1123,40 @@ let VisitorTrackingController = class VisitorTrackingController {
1072
1123
  return /[",\n]/.test(s) ? `"${s}"` : s;
1073
1124
  };
1074
1125
  res.write('createdAt,visitorId,sessionId,customerId,channelId,type,url,title,referrerDomain,country,region,city,browser,os,device,isBot,goalId,utmSource,utmMedium,utmCampaign\n');
1075
- for (const r of rows) {
1076
- res.write([
1077
- esc(r.createdAt instanceof Date ? r.createdAt.toISOString() : r.createdAt),
1078
- esc(r.visitorId), esc(r.sessionId), esc(r.customerId), esc(r.channelId),
1079
- esc(r.type), esc(r.url), esc(r.title), esc(r.referrerDomain),
1080
- esc(r.country), esc(r.region), esc(r.city), esc(r.browser), esc(r.os), esc(r.device),
1081
- esc(r.isBot), esc(r.goalId),
1082
- esc(r.utmSource), esc(r.utmMedium), esc(r.utmCampaign),
1083
- ].join(',') + '\n');
1126
+ // Streamed in id-ordered chunks: the export used to materialise up to
1127
+ // 200 000 rows before writing the first byte.
1128
+ const CHUNK = 5000;
1129
+ const MAX_ROWS = 200000;
1130
+ let lastId = 0;
1131
+ let written = 0;
1132
+ while (written < MAX_ROWS) {
1133
+ const rows = await (0, vendure_licence_sdk_1.adapterFor)(this.connection.rawConnection).query(`SELECT id, \`createdAt\`, \`visitorId\`, \`sessionId\`, \`customerId\`, \`channelId\`,
1134
+ type, url, title, \`referrerDomain\`,
1135
+ country, region, city, browser, os, device,
1136
+ \`isBot\`, \`goalId\`, \`utmSource\`, \`utmMedium\`, \`utmCampaign\`
1137
+ FROM visitor_event
1138
+ WHERE id > ? AND \`createdAt\` >= DATE_SUB(NOW(), INTERVAL ? DAY) ${w.sql}
1139
+ ORDER BY id
1140
+ LIMIT ?`, [lastId, days, ...w.params, Math.min(CHUNK, MAX_ROWS - written)]);
1141
+ if (!rows.length)
1142
+ break;
1143
+ let out = '';
1144
+ for (const r of rows) {
1145
+ out += [
1146
+ esc(r.createdAt instanceof Date ? r.createdAt.toISOString() : r.createdAt),
1147
+ esc(r.visitorId), esc(r.sessionId), esc(r.customerId), esc(r.channelId),
1148
+ esc(r.type), esc(r.url), esc(r.title), esc(r.referrerDomain),
1149
+ esc(r.country), esc(r.region), esc(r.city), esc(r.browser), esc(r.os), esc(r.device),
1150
+ esc(r.isBot === true ? 1 : r.isBot === false ? 0 : r.isBot), esc(r.goalId),
1151
+ esc(r.utmSource), esc(r.utmMedium), esc(r.utmCampaign),
1152
+ ].join(',') + '\n';
1153
+ }
1154
+ lastId = Number(rows[rows.length - 1].id);
1155
+ written += rows.length;
1156
+ if (!res.write(out))
1157
+ await drained(res);
1158
+ if (res.destroyed || res.writableEnded)
1159
+ return;
1084
1160
  }
1085
1161
  return res.end();
1086
1162
  }
@@ -1146,13 +1222,22 @@ __decorate([
1146
1222
  __metadata("design:paramtypes", [core_1.RequestContext, Object]),
1147
1223
  __metadata("design:returntype", Promise)
1148
1224
  ], VisitorTrackingController.prototype, "licencePortalLink", null);
1225
+ __decorate([
1226
+ (0, common_1.Options)('track'),
1227
+ __param(0, (0, common_1.Req)()),
1228
+ __param(1, (0, common_1.Res)()),
1229
+ __metadata("design:type", Function),
1230
+ __metadata("design:paramtypes", [Object, Object]),
1231
+ __metadata("design:returntype", void 0)
1232
+ ], VisitorTrackingController.prototype, "trackPreflight", null);
1149
1233
  __decorate([
1150
1234
  (0, common_1.Post)('track'),
1151
- __param(0, (0, common_1.Body)()),
1152
- __param(1, (0, common_1.Req)()),
1153
- __param(2, (0, common_1.Res)()),
1235
+ __param(0, (0, core_1.Ctx)()),
1236
+ __param(1, (0, common_1.Body)()),
1237
+ __param(2, (0, common_1.Req)()),
1238
+ __param(3, (0, common_1.Res)()),
1154
1239
  __metadata("design:type", Function),
1155
- __metadata("design:paramtypes", [Object, Object, Object]),
1240
+ __metadata("design:paramtypes", [core_1.RequestContext, Object, Object, Object]),
1156
1241
  __metadata("design:returntype", Promise)
1157
1242
  ], VisitorTrackingController.prototype, "track", null);
1158
1243
  __decorate([