@litemetrics/node 0.1.3 → 0.3.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/dist/index.cjs CHANGED
@@ -42,6 +42,36 @@ var import_client = require("@clickhouse/client");
42
42
 
43
43
  // src/adapters/utils.ts
44
44
  var import_crypto = require("crypto");
45
+ function getTimezoneOffsetMs(date, timezone) {
46
+ const formatter = new Intl.DateTimeFormat("en-US", {
47
+ timeZone: timezone,
48
+ year: "numeric",
49
+ month: "2-digit",
50
+ day: "2-digit",
51
+ hour: "2-digit",
52
+ minute: "2-digit",
53
+ second: "2-digit",
54
+ hour12: false
55
+ });
56
+ const parts = formatter.formatToParts(date);
57
+ const get = (type) => parts.find((p) => p.type === type).value;
58
+ const y = parseInt(get("year"));
59
+ const m = parseInt(get("month")) - 1;
60
+ const d = parseInt(get("day"));
61
+ const h = parseInt(get("hour") === "24" ? "0" : get("hour"));
62
+ const mi = parseInt(get("minute"));
63
+ const s = parseInt(get("second"));
64
+ return Date.UTC(y, m, d, h, mi, s) - date.getTime();
65
+ }
66
+ function toUTCDate(value) {
67
+ if (value instanceof Date) return value;
68
+ if (typeof value === "number") return new Date(value);
69
+ const s = String(value).trim();
70
+ if (s.length >= 10 && !s.endsWith("Z") && !/[+-]\d{2}:?\d{2}$/.test(s)) {
71
+ return /* @__PURE__ */ new Date(s.replace(" ", "T") + "Z");
72
+ }
73
+ return new Date(s);
74
+ }
45
75
  function resolvePeriod(q) {
46
76
  const now = /* @__PURE__ */ new Date();
47
77
  const period = q.period ?? "7d";
@@ -108,60 +138,66 @@ function granularityToDateFormat(g) {
108
138
  return "%Y-%m";
109
139
  }
110
140
  }
111
- function fillBuckets(from, to, granularity, dateFormat, rows) {
141
+ function fillBuckets(from, to, granularity, dateFormat, rows, timezone) {
112
142
  const map = new Map(rows.map((r) => [r._id, r.value]));
113
143
  const points = [];
114
- const current = new Date(from);
144
+ const fromOffset = timezone ? getTimezoneOffsetMs(from, timezone) : 0;
145
+ const toOffset = timezone ? getTimezoneOffsetMs(to, timezone) : 0;
146
+ const current = new Date(from.getTime() + fromOffset);
147
+ const toWall = new Date(to.getTime() + toOffset);
115
148
  if (granularity === "hour") {
116
- current.setMinutes(0, 0, 0);
149
+ current.setUTCMinutes(0, 0, 0);
117
150
  } else if (granularity === "day") {
118
- current.setHours(0, 0, 0, 0);
151
+ current.setUTCHours(0, 0, 0, 0);
119
152
  } else if (granularity === "week") {
120
- const day = current.getDay();
153
+ const day = current.getUTCDay();
121
154
  const diff = day === 0 ? -6 : 1 - day;
122
- current.setDate(current.getDate() + diff);
123
- current.setHours(0, 0, 0, 0);
155
+ current.setUTCDate(current.getUTCDate() + diff);
156
+ current.setUTCHours(0, 0, 0, 0);
124
157
  } else if (granularity === "month") {
125
- current.setDate(1);
126
- current.setHours(0, 0, 0, 0);
158
+ current.setUTCDate(1);
159
+ current.setUTCHours(0, 0, 0, 0);
127
160
  }
128
- while (current <= to) {
161
+ while (current <= toWall) {
129
162
  const key = formatDateBucket(current, dateFormat);
130
- points.push({ date: current.toISOString(), value: map.get(key) ?? 0 });
163
+ const approxUtc = new Date(current.getTime() - fromOffset);
164
+ const exactOffset = timezone ? getTimezoneOffsetMs(approxUtc, timezone) : 0;
165
+ const realUtc = new Date(current.getTime() - exactOffset);
166
+ points.push({ date: realUtc.toISOString(), value: map.get(key) ?? 0 });
131
167
  if (granularity === "hour") {
132
- current.setHours(current.getHours() + 1);
168
+ current.setUTCHours(current.getUTCHours() + 1);
133
169
  } else if (granularity === "day") {
134
- current.setDate(current.getDate() + 1);
170
+ current.setUTCDate(current.getUTCDate() + 1);
135
171
  } else if (granularity === "week") {
136
- current.setDate(current.getDate() + 7);
172
+ current.setUTCDate(current.getUTCDate() + 7);
137
173
  } else if (granularity === "month") {
138
- current.setMonth(current.getMonth() + 1);
174
+ current.setUTCMonth(current.getUTCMonth() + 1);
139
175
  }
140
176
  }
141
177
  return points;
142
178
  }
143
179
  function formatDateBucket(date, format) {
144
- const y = date.getFullYear();
145
- const m = String(date.getMonth() + 1).padStart(2, "0");
146
- const d = String(date.getDate()).padStart(2, "0");
147
- const h = String(date.getHours()).padStart(2, "0");
180
+ const y = date.getUTCFullYear();
181
+ const m = String(date.getUTCMonth() + 1).padStart(2, "0");
182
+ const d = String(date.getUTCDate()).padStart(2, "0");
183
+ const h = String(date.getUTCHours()).padStart(2, "0");
148
184
  if (format === "%Y-%m-%dT%H:00") return `${y}-${m}-${d}T${h}:00`;
149
185
  if (format === "%Y-%m-%d") return `${y}-${m}-${d}`;
150
186
  if (format === "%Y-%m") return `${y}-${m}`;
151
187
  if (format === "%G-W%V") {
152
- const jan4 = new Date(y, 0, 4);
153
- const dayOfYear = Math.ceil((date.getTime() - new Date(y, 0, 1).getTime()) / 864e5) + 1;
154
- const jan4Day = jan4.getDay() || 7;
188
+ const jan4 = new Date(Date.UTC(y, 0, 4));
189
+ const dayOfYear = Math.ceil((date.getTime() - Date.UTC(y, 0, 1)) / 864e5) + 1;
190
+ const jan4Day = jan4.getUTCDay() || 7;
155
191
  const weekNum = Math.ceil((dayOfYear + jan4Day - 1) / 7);
156
192
  return `${y}-W${String(weekNum).padStart(2, "0")}`;
157
193
  }
158
194
  return date.toISOString();
159
195
  }
160
196
  function getISOWeek(date) {
161
- const y = date.getFullYear();
162
- const jan4 = new Date(y, 0, 4);
163
- const dayOfYear = Math.ceil((date.getTime() - new Date(y, 0, 1).getTime()) / 864e5) + 1;
164
- const jan4Day = jan4.getDay() || 7;
197
+ const y = date.getUTCFullYear();
198
+ const jan4 = new Date(Date.UTC(y, 0, 4));
199
+ const dayOfYear = Math.ceil((date.getTime() - Date.UTC(y, 0, 1)) / 864e5) + 1;
200
+ const jan4Day = jan4.getUTCDay() || 7;
165
201
  const weekNum = Math.ceil((dayOfYear + jan4Day - 1) / 7);
166
202
  return `${y}-W${String(weekNum).padStart(2, "0")}`;
167
203
  }
@@ -176,6 +212,38 @@ function generateSecretKey() {
176
212
  return `sk_${(0, import_crypto.randomBytes)(32).toString("hex")}`;
177
213
  }
178
214
 
215
+ // src/query-helpers.ts
216
+ function isValidTimezone(tz) {
217
+ try {
218
+ Intl.DateTimeFormat(void 0, { timeZone: tz });
219
+ return true;
220
+ } catch {
221
+ return false;
222
+ }
223
+ }
224
+ function extractQueryParams(req) {
225
+ const q = req.query ?? Object.fromEntries(new URL(req.url, "http://localhost").searchParams);
226
+ let timezone;
227
+ if (typeof q.timezone === "string" && q.timezone) {
228
+ if (isValidTimezone(q.timezone)) {
229
+ timezone = q.timezone;
230
+ } else {
231
+ console.warn(`[litemetrics] Invalid timezone "${q.timezone}", falling back to UTC`);
232
+ }
233
+ }
234
+ return {
235
+ siteId: q.siteId,
236
+ metric: q.metric,
237
+ period: q.period,
238
+ dateFrom: q.dateFrom,
239
+ dateTo: q.dateTo,
240
+ limit: q.limit ? parseInt(q.limit, 10) : void 0,
241
+ filters: q.filters ? JSON.parse(q.filters) : void 0,
242
+ compare: q.compare === "true" || q.compare === "1",
243
+ timezone
244
+ };
245
+ }
246
+
179
247
  // src/adapters/clickhouse.ts
180
248
  var EVENTS_TABLE = "litemetrics_events";
181
249
  var SITES_TABLE = "litemetrics_sites";
@@ -229,6 +297,13 @@ CREATE TABLE IF NOT EXISTS ${EVENTS_TABLE} (
229
297
  utm_term Nullable(String),
230
298
  utm_content Nullable(String),
231
299
  ip Nullable(String),
300
+ os_version LowCardinality(Nullable(String)),
301
+ device_model LowCardinality(Nullable(String)),
302
+ device_brand LowCardinality(Nullable(String)),
303
+ app_version LowCardinality(Nullable(String)),
304
+ app_build Nullable(String),
305
+ sdk_name LowCardinality(Nullable(String)),
306
+ sdk_version LowCardinality(Nullable(String)),
232
307
  created_at DateTime64(3) DEFAULT now64(3)
233
308
  ) ENGINE = MergeTree()
234
309
  PARTITION BY toYYYYMM(timestamp)
@@ -240,6 +315,7 @@ CREATE TABLE IF NOT EXISTS ${SITES_TABLE} (
240
315
  site_id String,
241
316
  secret_key String,
242
317
  name String,
318
+ type LowCardinality(Nullable(String)) DEFAULT 'web',
243
319
  domain Nullable(String),
244
320
  allowed_origins Nullable(String),
245
321
  conversion_events Nullable(String),
@@ -252,8 +328,74 @@ CREATE TABLE IF NOT EXISTS ${SITES_TABLE} (
252
328
  SETTINGS index_granularity = 8192
253
329
  `;
254
330
  function toCHDateTime(d) {
255
- const iso = typeof d === "string" ? d : d.toISOString();
256
- return iso.replace("T", " ").replace("Z", "");
331
+ const date = d instanceof Date ? d : toUTCDate(d);
332
+ const y = date.getUTCFullYear();
333
+ const mo = String(date.getUTCMonth() + 1).padStart(2, "0");
334
+ const da = String(date.getUTCDate()).padStart(2, "0");
335
+ const h = String(date.getUTCHours()).padStart(2, "0");
336
+ const mi = String(date.getUTCMinutes()).padStart(2, "0");
337
+ const s = String(date.getUTCSeconds()).padStart(2, "0");
338
+ const ms = String(date.getUTCMilliseconds()).padStart(3, "0");
339
+ return `${y}-${mo}-${da} ${h}:${mi}:${s}.${ms}`;
340
+ }
341
+ function normalizedUtmSourceExpr() {
342
+ return `multiIf(
343
+ lower(utm_source) IN ('ig','instagram','instagram.com'), 'Instagram',
344
+ lower(utm_source) IN ('fb','facebook','facebook.com','fb.com'), 'Facebook',
345
+ lower(utm_source) IN ('tw','twitter','twitter.com','x','x.com','t.co'), 'X (Twitter)',
346
+ lower(utm_source) IN ('li','linkedin','linkedin.com'), 'LinkedIn',
347
+ lower(utm_source) IN ('yt','youtube','youtube.com'), 'YouTube',
348
+ lower(utm_source) IN ('goog','google','google.com'), 'Google',
349
+ lower(utm_source) IN ('gh','github','github.com'), 'GitHub',
350
+ lower(utm_source) IN ('reddit','reddit.com'), 'Reddit',
351
+ lower(utm_source) IN ('pinterest','pinterest.com'), 'Pinterest',
352
+ lower(utm_source) IN ('tiktok','tiktok.com'), 'TikTok',
353
+ lower(utm_source) IN ('openai','chatgpt','chat.openai.com'), 'OpenAI',
354
+ lower(utm_source) IN ('perplexity','perplexity.ai'), 'Perplexity',
355
+ utm_source
356
+ )`;
357
+ }
358
+ function normalizedUtmMediumExpr() {
359
+ return `multiIf(
360
+ lower(utm_medium) IN ('cpc','ppc','paidsearch','paid-search','paid_search','paid'), 'Paid',
361
+ lower(utm_medium) IN ('organic'), 'Organic',
362
+ lower(utm_medium) IN ('social','social-media','social_media'), 'Social',
363
+ lower(utm_medium) IN ('email','e-mail','e_mail'), 'Email',
364
+ lower(utm_medium) IN ('display','banner','cpm'), 'Display',
365
+ lower(utm_medium) IN ('affiliate'), 'Affiliate',
366
+ lower(utm_medium) IN ('referral'), 'Referral',
367
+ utm_medium
368
+ )`;
369
+ }
370
+ function channelClassificationExpr() {
371
+ return `multiIf(
372
+ lower(ifNull(utm_medium,'')) IN ('cpc','ppc','paidsearch','paid-search','paid_search','paid')
373
+ AND (lower(ifNull(utm_source,'')) IN ('google','goog','bing','yahoo','duckduckgo','ecosia','baidu','yandex')
374
+ OR multiSearchAnyCaseInsensitive(ifNull(referrer,''), ['google','bing','yahoo','duckduckgo','ecosia','baidu','yandex','search.brave']) > 0),
375
+ 'Paid Search',
376
+ lower(ifNull(utm_medium,'')) IN ('cpc','ppc','paidsearch','paid-search','paid_search','paid')
377
+ AND (lower(ifNull(utm_source,'')) IN ('instagram','ig','facebook','fb','twitter','tw','x','linkedin','li','youtube','yt','tiktok','pinterest','reddit','snapchat')
378
+ OR multiSearchAnyCaseInsensitive(ifNull(referrer,''), ['instagram','facebook','twitter','x.com','t.co','linkedin','youtube','tiktok','pinterest','reddit','snapchat']) > 0),
379
+ 'Paid Social',
380
+ lower(ifNull(utm_medium,'')) IN ('email','e-mail','e_mail'),
381
+ 'Email',
382
+ lower(ifNull(utm_medium,'')) IN ('display','banner','cpm'),
383
+ 'Display',
384
+ lower(ifNull(utm_medium,'')) IN ('affiliate'),
385
+ 'Affiliate',
386
+ multiSearchAnyCaseInsensitive(ifNull(referrer,''), ['google','bing','yahoo','duckduckgo','ecosia','baidu','yandex','search.brave']) > 0
387
+ AND (ifNull(utm_medium,'') = '' OR lower(utm_medium) NOT IN ('cpc','ppc','paidsearch','paid-search','paid_search','paid')),
388
+ 'Organic Search',
389
+ (multiSearchAnyCaseInsensitive(ifNull(referrer,''), ['instagram','facebook','twitter','x.com','t.co','linkedin','youtube','tiktok','pinterest','reddit','snapchat','mastodon','tumblr']) > 0
390
+ OR lower(ifNull(utm_source,'')) IN ('instagram','ig','facebook','fb','twitter','tw','x','linkedin','li','youtube','yt','tiktok','pinterest','reddit','snapchat'))
391
+ AND (ifNull(utm_medium,'') = '' OR lower(utm_medium) NOT IN ('cpc','ppc','paidsearch','paid-search','paid_search','paid')),
392
+ 'Organic Social',
393
+ ifNull(referrer,'') != '' AND length(ifNull(referrer,'')) > 0,
394
+ 'Referral',
395
+ (ifNull(utm_source,'') != '' OR ifNull(utm_medium,'') != '' OR ifNull(utm_campaign,'') != ''),
396
+ 'Other',
397
+ 'Direct'
398
+ )`;
257
399
  }
258
400
  function buildFilterConditions(filters) {
259
401
  if (!filters) return { conditions: [], params: {} };
@@ -265,6 +407,10 @@ function buildFilterConditions(filters) {
265
407
  "device.type": "device_type",
266
408
  "device.browser": "browser",
267
409
  "device.os": "os",
410
+ "device.osVersion": "os_version",
411
+ "device.deviceModel": "device_model",
412
+ "device.deviceBrand": "device_brand",
413
+ "device.appVersion": "app_version",
268
414
  "utm.source": "utm_source",
269
415
  "utm.medium": "utm_medium",
270
416
  "utm.campaign": "utm_campaign",
@@ -281,7 +427,14 @@ function buildFilterConditions(filters) {
281
427
  const conditions = [];
282
428
  const params = {};
283
429
  for (const [key, value] of Object.entries(filters)) {
284
- if (!value || !map[key]) continue;
430
+ if (!value) continue;
431
+ if (key === "channel") {
432
+ const paramKey2 = "f_channel";
433
+ conditions.push(`${channelClassificationExpr()} = {${paramKey2}:String}`);
434
+ params[paramKey2] = value;
435
+ continue;
436
+ }
437
+ if (!map[key]) continue;
285
438
  const paramKey = `f_${key.replace(/[^a-zA-Z0-9]/g, "_")}`;
286
439
  conditions.push(`${map[key]} = {${paramKey}:String}`);
287
440
  params[paramKey] = value;
@@ -312,6 +465,14 @@ var ClickHouseAdapter = class {
312
465
  await this.client.command({
313
466
  query: `ALTER TABLE ${SITES_TABLE} ADD COLUMN IF NOT EXISTS conversion_events Nullable(String)`
314
467
  });
468
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS os_version LowCardinality(Nullable(String))` });
469
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS device_model LowCardinality(Nullable(String))` });
470
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS device_brand LowCardinality(Nullable(String))` });
471
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS app_version LowCardinality(Nullable(String))` });
472
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS app_build Nullable(String)` });
473
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS sdk_name LowCardinality(Nullable(String))` });
474
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS sdk_version LowCardinality(Nullable(String))` });
475
+ await this.client.command({ query: `ALTER TABLE ${SITES_TABLE} ADD COLUMN IF NOT EXISTS type LowCardinality(Nullable(String)) DEFAULT 'web'` });
315
476
  }
316
477
  async close() {
317
478
  await this.client.close();
@@ -354,7 +515,14 @@ var ClickHouseAdapter = class {
354
515
  utm_campaign: e.utm?.campaign ?? null,
355
516
  utm_term: e.utm?.term ?? null,
356
517
  utm_content: e.utm?.content ?? null,
357
- ip: e.ip ?? null
518
+ ip: e.ip ?? null,
519
+ os_version: e.device?.osVersion ?? null,
520
+ device_model: e.device?.deviceModel ?? null,
521
+ device_brand: e.device?.deviceBrand ?? null,
522
+ app_version: e.device?.appVersion ?? null,
523
+ app_build: e.device?.appBuild ?? null,
524
+ sdk_name: e.device?.sdkName ?? null,
525
+ sdk_version: e.device?.sdkVersion ?? null
358
526
  }));
359
527
  await this.client.insert({
360
528
  table: EVENTS_TABLE,
@@ -705,15 +873,67 @@ var ClickHouseAdapter = class {
705
873
  total = data.reduce((sum, d) => sum + d.value, 0);
706
874
  break;
707
875
  }
876
+ case "top_os_versions": {
877
+ const rows = await this.queryRows(
878
+ `SELECT concat(os, ' ', ifNull(os_version, '')) AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
879
+ WHERE site_id = {siteId:String}
880
+ AND timestamp >= {from:String}
881
+ AND timestamp <= {to:String}
882
+ AND os IS NOT NULL
883
+ AND os_version IS NOT NULL
884
+ ${filterSql}
885
+ GROUP BY key
886
+ ORDER BY value DESC
887
+ LIMIT {limit:UInt32}`,
888
+ { ...params, ...filter.params }
889
+ );
890
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
891
+ total = data.reduce((sum, d) => sum + d.value, 0);
892
+ break;
893
+ }
894
+ case "top_device_models": {
895
+ const rows = await this.queryRows(
896
+ `SELECT trim(concat(ifNull(device_brand, ''), ' ', device_model)) AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
897
+ WHERE site_id = {siteId:String}
898
+ AND timestamp >= {from:String}
899
+ AND timestamp <= {to:String}
900
+ AND device_model IS NOT NULL
901
+ ${filterSql}
902
+ GROUP BY key
903
+ ORDER BY value DESC
904
+ LIMIT {limit:UInt32}`,
905
+ { ...params, ...filter.params }
906
+ );
907
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
908
+ total = data.reduce((sum, d) => sum + d.value, 0);
909
+ break;
910
+ }
911
+ case "top_app_versions": {
912
+ const rows = await this.queryRows(
913
+ `SELECT app_version AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
914
+ WHERE site_id = {siteId:String}
915
+ AND timestamp >= {from:String}
916
+ AND timestamp <= {to:String}
917
+ AND app_version IS NOT NULL
918
+ ${filterSql}
919
+ GROUP BY app_version
920
+ ORDER BY value DESC
921
+ LIMIT {limit:UInt32}`,
922
+ { ...params, ...filter.params }
923
+ );
924
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
925
+ total = data.reduce((sum, d) => sum + d.value, 0);
926
+ break;
927
+ }
708
928
  case "top_utm_sources": {
709
929
  const rows = await this.queryRows(
710
- `SELECT utm_source AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
930
+ `SELECT ${normalizedUtmSourceExpr()} AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
711
931
  WHERE site_id = {siteId:String}
712
932
  AND timestamp >= {from:String}
713
933
  AND timestamp <= {to:String}
714
934
  AND utm_source IS NOT NULL AND utm_source != ''
715
935
  ${filterSql}
716
- GROUP BY utm_source
936
+ GROUP BY key
717
937
  ORDER BY value DESC
718
938
  LIMIT {limit:UInt32}`,
719
939
  { ...params, ...filter.params }
@@ -724,13 +944,13 @@ var ClickHouseAdapter = class {
724
944
  }
725
945
  case "top_utm_mediums": {
726
946
  const rows = await this.queryRows(
727
- `SELECT utm_medium AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
947
+ `SELECT ${normalizedUtmMediumExpr()} AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
728
948
  WHERE site_id = {siteId:String}
729
949
  AND timestamp >= {from:String}
730
950
  AND timestamp <= {to:String}
731
951
  AND utm_medium IS NOT NULL AND utm_medium != ''
732
952
  ${filterSql}
733
- GROUP BY utm_medium
953
+ GROUP BY key
734
954
  ORDER BY value DESC
735
955
  LIMIT {limit:UInt32}`,
736
956
  { ...params, ...filter.params }
@@ -756,6 +976,56 @@ var ClickHouseAdapter = class {
756
976
  total = data.reduce((sum, d) => sum + d.value, 0);
757
977
  break;
758
978
  }
979
+ case "top_utm_terms": {
980
+ const rows = await this.queryRows(
981
+ `SELECT utm_term AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
982
+ WHERE site_id = {siteId:String}
983
+ AND timestamp >= {from:String}
984
+ AND timestamp <= {to:String}
985
+ AND utm_term IS NOT NULL AND utm_term != ''
986
+ ${filterSql}
987
+ GROUP BY utm_term
988
+ ORDER BY value DESC
989
+ LIMIT {limit:UInt32}`,
990
+ { ...params, ...filter.params }
991
+ );
992
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
993
+ total = data.reduce((sum, d) => sum + d.value, 0);
994
+ break;
995
+ }
996
+ case "top_utm_contents": {
997
+ const rows = await this.queryRows(
998
+ `SELECT utm_content AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
999
+ WHERE site_id = {siteId:String}
1000
+ AND timestamp >= {from:String}
1001
+ AND timestamp <= {to:String}
1002
+ AND utm_content IS NOT NULL AND utm_content != ''
1003
+ ${filterSql}
1004
+ GROUP BY utm_content
1005
+ ORDER BY value DESC
1006
+ LIMIT {limit:UInt32}`,
1007
+ { ...params, ...filter.params }
1008
+ );
1009
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
1010
+ total = data.reduce((sum, d) => sum + d.value, 0);
1011
+ break;
1012
+ }
1013
+ case "top_channels": {
1014
+ const rows = await this.queryRows(
1015
+ `SELECT ${channelClassificationExpr()} AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
1016
+ WHERE site_id = {siteId:String}
1017
+ AND timestamp >= {from:String}
1018
+ AND timestamp <= {to:String}
1019
+ ${filterSql}
1020
+ GROUP BY key
1021
+ ORDER BY value DESC
1022
+ LIMIT {limit:UInt32}`,
1023
+ { ...params, ...filter.params }
1024
+ );
1025
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
1026
+ total = data.reduce((sum, d) => sum + d.value, 0);
1027
+ break;
1028
+ }
759
1029
  }
760
1030
  const result = { metric: q.metric, period, data, total };
761
1031
  if (q.compare && ["pageviews", "visitors", "sessions", "events", "conversions"].includes(q.metric)) {
@@ -786,7 +1056,7 @@ var ClickHouseAdapter = class {
786
1056
  dateTo: params.dateTo
787
1057
  });
788
1058
  const granularity = params.granularity ?? autoGranularity(period);
789
- const bucketFn = this.granularityToClickHouseFunc(granularity);
1059
+ const bucketFn = this.granularityToClickHouseFunc(granularity, params.timezone);
790
1060
  const dateFormat = granularityToDateFormat(granularity);
791
1061
  const filter = buildFilterConditions(params.filters);
792
1062
  const filterSql = filter.conditions.length > 0 ? ` AND ${filter.conditions.join(" AND ")}` : "";
@@ -835,38 +1105,41 @@ var ClickHouseAdapter = class {
835
1105
  new Date(dateRange.to),
836
1106
  granularity,
837
1107
  dateFormat,
838
- mappedRows
1108
+ mappedRows,
1109
+ params.timezone
839
1110
  );
840
1111
  return { metric: params.metric, granularity, data };
841
1112
  }
842
- granularityToClickHouseFunc(g) {
1113
+ granularityToClickHouseFunc(g, timezone) {
1114
+ const safeTz = timezone && isValidTimezone(timezone) ? timezone : void 0;
1115
+ const tz = safeTz ? `, '${safeTz}'` : "";
843
1116
  switch (g) {
844
1117
  case "hour":
845
- return "toStartOfHour(timestamp)";
1118
+ return `toStartOfHour(timestamp${tz})`;
846
1119
  case "day":
847
- return "toStartOfDay(timestamp)";
1120
+ return `toStartOfDay(timestamp${tz})`;
848
1121
  case "week":
849
- return "toStartOfWeek(timestamp, 1)";
1122
+ return `toStartOfWeek(timestamp, 1${tz})`;
850
1123
  // 1 = Monday
851
1124
  case "month":
852
- return "toStartOfMonth(timestamp)";
1125
+ return `toStartOfMonth(timestamp${tz})`;
853
1126
  }
854
1127
  }
855
1128
  convertClickHouseBucket(bucket, granularity) {
856
- const date = new Date(bucket);
857
- const y = date.getFullYear();
858
- const m = String(date.getMonth() + 1).padStart(2, "0");
859
- const d = String(date.getDate()).padStart(2, "0");
860
- const h = String(date.getHours()).padStart(2, "0");
1129
+ const date = toUTCDate(bucket);
1130
+ const y = date.getUTCFullYear();
1131
+ const m = String(date.getUTCMonth() + 1).padStart(2, "0");
1132
+ const d = String(date.getUTCDate()).padStart(2, "0");
1133
+ const h = String(date.getUTCHours()).padStart(2, "0");
861
1134
  switch (granularity) {
862
1135
  case "hour":
863
1136
  return `${y}-${m}-${d}T${h}:00`;
864
1137
  case "day":
865
1138
  return `${y}-${m}-${d}`;
866
1139
  case "week": {
867
- const jan4 = new Date(y, 0, 4);
868
- const dayOfYear = Math.ceil((date.getTime() - new Date(y, 0, 1).getTime()) / 864e5) + 1;
869
- const jan4Day = jan4.getDay() || 7;
1140
+ const jan4 = new Date(Date.UTC(y, 0, 4));
1141
+ const dayOfYear = Math.ceil((date.getTime() - Date.UTC(y, 0, 1)) / 864e5) + 1;
1142
+ const jan4Day = jan4.getUTCDay() || 7;
870
1143
  const weekNum = Math.ceil((dayOfYear + jan4Day - 1) / 7);
871
1144
  return `${y}-W${String(weekNum).padStart(2, "0")}`;
872
1145
  }
@@ -895,7 +1168,7 @@ var ClickHouseAdapter = class {
895
1168
  );
896
1169
  const cohortMap = /* @__PURE__ */ new Map();
897
1170
  for (const v of rows) {
898
- const firstDate = new Date(v.first_event);
1171
+ const firstDate = toUTCDate(v.first_event);
899
1172
  const cohortWeek = getISOWeek(firstDate);
900
1173
  if (!cohortMap.has(cohortWeek)) {
901
1174
  cohortMap.set(cohortWeek, { visitors: /* @__PURE__ */ new Set(), weekSets: /* @__PURE__ */ new Map() });
@@ -903,7 +1176,7 @@ var ClickHouseAdapter = class {
903
1176
  const cohort = cohortMap.get(cohortWeek);
904
1177
  cohort.visitors.add(v.visitor_id);
905
1178
  const eventWeeks = (Array.isArray(v.active_weeks) ? v.active_weeks : []).map((w) => {
906
- const d = new Date(w);
1179
+ const d = toUTCDate(w);
907
1180
  return getISOWeek(d);
908
1181
  });
909
1182
  for (const w of eventWeeks) {
@@ -1070,8 +1343,8 @@ var ClickHouseAdapter = class {
1070
1343
  visitorId: String(u.visitor_id),
1071
1344
  userId: u.userId ? String(u.userId) : void 0,
1072
1345
  traits: this.parseJSON(u.traits),
1073
- firstSeen: new Date(String(u.firstSeen)).toISOString(),
1074
- lastSeen: new Date(String(u.lastSeen)).toISOString(),
1346
+ firstSeen: toUTCDate(String(u.firstSeen)).toISOString(),
1347
+ lastSeen: toUTCDate(String(u.lastSeen)).toISOString(),
1075
1348
  totalEvents: Number(u.totalEvents),
1076
1349
  totalPageviews: Number(u.totalPageviews),
1077
1350
  totalSessions: Number(u.totalSessions),
@@ -1158,7 +1431,7 @@ var ClickHouseAdapter = class {
1158
1431
  async getMergedUserDetail(siteId, userId, visitorIds) {
1159
1432
  const rows = await this.queryRows(
1160
1433
  `SELECT
1161
- anyLast(visitor_id) AS visitor_id,
1434
+ anyLast(visitor_id) AS last_visitor_id,
1162
1435
  anyLast(traits) AS traits,
1163
1436
  min(timestamp) AS firstSeen,
1164
1437
  max(timestamp) AS lastSeen,
@@ -1190,12 +1463,12 @@ var ClickHouseAdapter = class {
1190
1463
  if (rows.length === 0) return null;
1191
1464
  const u = rows[0];
1192
1465
  return {
1193
- visitorId: String(u.visitor_id),
1466
+ visitorId: String(u.last_visitor_id),
1194
1467
  visitorIds,
1195
1468
  userId,
1196
1469
  traits: this.parseJSON(u.traits),
1197
- firstSeen: new Date(String(u.firstSeen)).toISOString(),
1198
- lastSeen: new Date(String(u.lastSeen)).toISOString(),
1470
+ firstSeen: toUTCDate(String(u.firstSeen)).toISOString(),
1471
+ lastSeen: toUTCDate(String(u.lastSeen)).toISOString(),
1199
1472
  totalEvents: Number(u.totalEvents),
1200
1473
  totalPageviews: Number(u.totalPageviews),
1201
1474
  totalSessions: Number(u.totalSessions),
@@ -1279,6 +1552,7 @@ var ClickHouseAdapter = class {
1279
1552
  siteId: generateSiteId(),
1280
1553
  secretKey: generateSecretKey(),
1281
1554
  name: data.name,
1555
+ type: data.type ?? "web",
1282
1556
  domain: data.domain,
1283
1557
  allowedOrigins: data.allowedOrigins,
1284
1558
  conversionEvents: data.conversionEvents,
@@ -1291,6 +1565,7 @@ var ClickHouseAdapter = class {
1291
1565
  site_id: site.siteId,
1292
1566
  secret_key: site.secretKey,
1293
1567
  name: site.name,
1568
+ type: site.type ?? "web",
1294
1569
  domain: site.domain ?? null,
1295
1570
  allowed_origins: site.allowedOrigins ? JSON.stringify(site.allowedOrigins) : null,
1296
1571
  conversion_events: site.conversionEvents ? JSON.stringify(site.conversionEvents) : null,
@@ -1305,7 +1580,7 @@ var ClickHouseAdapter = class {
1305
1580
  }
1306
1581
  async getSite(siteId) {
1307
1582
  const rows = await this.queryRows(
1308
- `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, updated_at
1583
+ `SELECT site_id, secret_key, name, type, domain, allowed_origins, conversion_events, created_at, updated_at
1309
1584
  FROM ${SITES_TABLE} FINAL
1310
1585
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
1311
1586
  { siteId }
@@ -1314,7 +1589,7 @@ var ClickHouseAdapter = class {
1314
1589
  }
1315
1590
  async getSiteBySecret(secretKey) {
1316
1591
  const rows = await this.queryRows(
1317
- `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, updated_at
1592
+ `SELECT site_id, secret_key, name, type, domain, allowed_origins, conversion_events, created_at, updated_at
1318
1593
  FROM ${SITES_TABLE} FINAL
1319
1594
  WHERE secret_key = {secretKey:String} AND is_deleted = 0`,
1320
1595
  { secretKey }
@@ -1323,7 +1598,7 @@ var ClickHouseAdapter = class {
1323
1598
  }
1324
1599
  async listSites() {
1325
1600
  const rows = await this.queryRows(
1326
- `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, updated_at
1601
+ `SELECT site_id, secret_key, name, type, domain, allowed_origins, conversion_events, created_at, updated_at
1327
1602
  FROM ${SITES_TABLE} FINAL
1328
1603
  WHERE is_deleted = 0
1329
1604
  ORDER BY created_at DESC`,
@@ -1333,7 +1608,7 @@ var ClickHouseAdapter = class {
1333
1608
  }
1334
1609
  async updateSite(siteId, data) {
1335
1610
  const currentRows = await this.queryRows(
1336
- `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, updated_at, version
1611
+ `SELECT site_id, secret_key, name, type, domain, allowed_origins, conversion_events, created_at, updated_at, version
1337
1612
  FROM ${SITES_TABLE} FINAL
1338
1613
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
1339
1614
  { siteId }
@@ -1345,6 +1620,7 @@ var ClickHouseAdapter = class {
1345
1620
  const nowCH = toCHDateTime(now);
1346
1621
  const newVersion = Number(current.version) + 1;
1347
1622
  const newName = data.name !== void 0 ? data.name : String(current.name);
1623
+ const newType = data.type !== void 0 ? data.type : current.type ? String(current.type) : "web";
1348
1624
  const newDomain = data.domain !== void 0 ? data.domain || null : current.domain ? String(current.domain) : null;
1349
1625
  const newOrigins = data.allowedOrigins !== void 0 ? data.allowedOrigins.length > 0 ? JSON.stringify(data.allowedOrigins) : null : current.allowed_origins ? String(current.allowed_origins) : null;
1350
1626
  const newConversions = data.conversionEvents !== void 0 ? data.conversionEvents.length > 0 ? JSON.stringify(data.conversionEvents) : null : current.conversion_events ? String(current.conversion_events) : null;
@@ -1354,6 +1630,7 @@ var ClickHouseAdapter = class {
1354
1630
  site_id: String(current.site_id),
1355
1631
  secret_key: String(current.secret_key),
1356
1632
  name: newName,
1633
+ type: newType,
1357
1634
  domain: newDomain,
1358
1635
  allowed_origins: newOrigins,
1359
1636
  conversion_events: newConversions,
@@ -1368,6 +1645,7 @@ var ClickHouseAdapter = class {
1368
1645
  siteId: String(current.site_id),
1369
1646
  secretKey: String(current.secret_key),
1370
1647
  name: newName,
1648
+ type: newType,
1371
1649
  domain: newDomain ?? void 0,
1372
1650
  allowedOrigins: newOrigins ? JSON.parse(newOrigins) : void 0,
1373
1651
  conversionEvents: newConversions ? JSON.parse(newConversions) : void 0,
@@ -1377,7 +1655,7 @@ var ClickHouseAdapter = class {
1377
1655
  }
1378
1656
  async deleteSite(siteId) {
1379
1657
  const currentRows = await this.queryRows(
1380
- `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, version
1658
+ `SELECT site_id, secret_key, name, type, domain, allowed_origins, conversion_events, created_at, version
1381
1659
  FROM ${SITES_TABLE} FINAL
1382
1660
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
1383
1661
  { siteId }
@@ -1391,6 +1669,7 @@ var ClickHouseAdapter = class {
1391
1669
  site_id: String(current.site_id),
1392
1670
  secret_key: String(current.secret_key),
1393
1671
  name: String(current.name),
1672
+ type: current.type ? String(current.type) : "web",
1394
1673
  domain: current.domain ? String(current.domain) : null,
1395
1674
  allowed_origins: current.allowed_origins ? String(current.allowed_origins) : null,
1396
1675
  conversion_events: current.conversion_events ? String(current.conversion_events) : null,
@@ -1405,7 +1684,7 @@ var ClickHouseAdapter = class {
1405
1684
  }
1406
1685
  async regenerateSecret(siteId) {
1407
1686
  const currentRows = await this.queryRows(
1408
- `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, version
1687
+ `SELECT site_id, secret_key, name, type, domain, allowed_origins, conversion_events, created_at, version
1409
1688
  FROM ${SITES_TABLE} FINAL
1410
1689
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
1411
1690
  { siteId }
@@ -1422,6 +1701,7 @@ var ClickHouseAdapter = class {
1422
1701
  site_id: String(current.site_id),
1423
1702
  secret_key: newSecret,
1424
1703
  name: String(current.name),
1704
+ type: current.type ? String(current.type) : "web",
1425
1705
  domain: current.domain ? String(current.domain) : null,
1426
1706
  allowed_origins: current.allowed_origins ? String(current.allowed_origins) : null,
1427
1707
  conversion_events: current.conversion_events ? String(current.conversion_events) : null,
@@ -1436,6 +1716,7 @@ var ClickHouseAdapter = class {
1436
1716
  siteId: String(current.site_id),
1437
1717
  secretKey: newSecret,
1438
1718
  name: String(current.name),
1719
+ type: current.type ? String(current.type) : "web",
1439
1720
  domain: current.domain ? String(current.domain) : void 0,
1440
1721
  allowedOrigins: current.allowed_origins ? JSON.parse(String(current.allowed_origins)) : void 0,
1441
1722
  conversionEvents: current.conversion_events ? JSON.parse(String(current.conversion_events)) : void 0,
@@ -1457,18 +1738,19 @@ var ClickHouseAdapter = class {
1457
1738
  siteId: String(row.site_id),
1458
1739
  secretKey: String(row.secret_key),
1459
1740
  name: String(row.name),
1741
+ type: row.type ? String(row.type) : "web",
1460
1742
  domain: row.domain ? String(row.domain) : void 0,
1461
1743
  allowedOrigins: row.allowed_origins ? JSON.parse(String(row.allowed_origins)) : void 0,
1462
1744
  conversionEvents: row.conversion_events ? JSON.parse(String(row.conversion_events)) : void 0,
1463
- createdAt: new Date(String(row.created_at)).toISOString(),
1464
- updatedAt: new Date(String(row.updated_at)).toISOString()
1745
+ createdAt: toUTCDate(String(row.created_at)).toISOString(),
1746
+ updatedAt: toUTCDate(String(row.updated_at)).toISOString()
1465
1747
  };
1466
1748
  }
1467
1749
  toEventListItem(row) {
1468
1750
  return {
1469
1751
  id: String(row.event_id ?? ""),
1470
1752
  type: String(row.type),
1471
- timestamp: new Date(String(row.timestamp)).toISOString(),
1753
+ timestamp: toUTCDate(String(row.timestamp)).toISOString(),
1472
1754
  visitorId: String(row.visitor_id),
1473
1755
  sessionId: String(row.session_id),
1474
1756
  url: row.url ? String(row.url) : void 0,
@@ -1520,6 +1802,130 @@ var import_mongodb = require("mongodb");
1520
1802
  var EVENTS_COLLECTION = "litemetrics_events";
1521
1803
  var SITES_COLLECTION = "litemetrics_sites";
1522
1804
  var IDENTITY_MAP_COLLECTION = "litemetrics_identity_map";
1805
+ function normalizedUtmSourceSwitch() {
1806
+ return {
1807
+ $switch: {
1808
+ branches: [
1809
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["ig", "instagram", "instagram.com"]] }, then: "Instagram" },
1810
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["fb", "facebook", "facebook.com", "fb.com"]] }, then: "Facebook" },
1811
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["tw", "twitter", "twitter.com", "x", "x.com", "t.co"]] }, then: "X (Twitter)" },
1812
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["li", "linkedin", "linkedin.com"]] }, then: "LinkedIn" },
1813
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["yt", "youtube", "youtube.com"]] }, then: "YouTube" },
1814
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["goog", "google", "google.com"]] }, then: "Google" },
1815
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["gh", "github", "github.com"]] }, then: "GitHub" },
1816
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["reddit", "reddit.com"]] }, then: "Reddit" },
1817
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["pinterest", "pinterest.com"]] }, then: "Pinterest" },
1818
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["tiktok", "tiktok.com"]] }, then: "TikTok" },
1819
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["openai", "chatgpt", "chat.openai.com"]] }, then: "OpenAI" },
1820
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["perplexity", "perplexity.ai"]] }, then: "Perplexity" }
1821
+ ],
1822
+ default: "$utm_source"
1823
+ }
1824
+ };
1825
+ }
1826
+ function normalizedUtmMediumSwitch() {
1827
+ return {
1828
+ $switch: {
1829
+ branches: [
1830
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["cpc", "ppc", "paidsearch", "paid-search", "paid_search", "paid"]] }, then: "Paid" },
1831
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["organic"]] }, then: "Organic" },
1832
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["social", "social-media", "social_media"]] }, then: "Social" },
1833
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["email", "e-mail", "e_mail"]] }, then: "Email" },
1834
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["display", "banner", "cpm"]] }, then: "Display" },
1835
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["affiliate"]] }, then: "Affiliate" },
1836
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["referral"]] }, then: "Referral" }
1837
+ ],
1838
+ default: "$utm_medium"
1839
+ }
1840
+ };
1841
+ }
1842
+ var SEARCH_ENGINES = /google|bing|yahoo|duckduckgo|ecosia|baidu|yandex|search\.brave/i;
1843
+ var SOCIAL_NETWORKS = /instagram|facebook|twitter|x\.com|t\.co|linkedin|youtube|tiktok|pinterest|reddit|snapchat|mastodon|tumblr/i;
1844
+ var PAID_MEDIUMS = ["cpc", "ppc", "paidsearch", "paid-search", "paid_search", "paid"];
1845
+ var SOCIAL_SOURCES = ["instagram", "ig", "facebook", "fb", "twitter", "tw", "x", "linkedin", "li", "youtube", "yt", "tiktok", "pinterest", "reddit", "snapchat"];
1846
+ function channelClassificationSwitch() {
1847
+ const lMedium = { $toLower: { $ifNull: ["$utm_medium", ""] } };
1848
+ const lSource = { $toLower: { $ifNull: ["$utm_source", ""] } };
1849
+ const refStr = { $ifNull: ["$referrer", ""] };
1850
+ return {
1851
+ $switch: {
1852
+ branches: [
1853
+ // Paid Search
1854
+ {
1855
+ case: {
1856
+ $and: [
1857
+ { $in: [lMedium, PAID_MEDIUMS] },
1858
+ { $or: [
1859
+ { $in: [lSource, ["google", "goog", "bing", "yahoo", "duckduckgo", "ecosia", "baidu", "yandex"]] },
1860
+ { $regexMatch: { input: refStr, regex: SEARCH_ENGINES } }
1861
+ ] }
1862
+ ]
1863
+ },
1864
+ then: "Paid Search"
1865
+ },
1866
+ // Paid Social
1867
+ {
1868
+ case: {
1869
+ $and: [
1870
+ { $in: [lMedium, PAID_MEDIUMS] },
1871
+ { $or: [
1872
+ { $in: [lSource, SOCIAL_SOURCES] },
1873
+ { $regexMatch: { input: refStr, regex: SOCIAL_NETWORKS } }
1874
+ ] }
1875
+ ]
1876
+ },
1877
+ then: "Paid Social"
1878
+ },
1879
+ // Email
1880
+ { case: { $in: [lMedium, ["email", "e-mail", "e_mail"]] }, then: "Email" },
1881
+ // Display
1882
+ { case: { $in: [lMedium, ["display", "banner", "cpm"]] }, then: "Display" },
1883
+ // Affiliate
1884
+ { case: { $in: [lMedium, ["affiliate"]] }, then: "Affiliate" },
1885
+ // Organic Search
1886
+ {
1887
+ case: {
1888
+ $and: [
1889
+ { $regexMatch: { input: refStr, regex: SEARCH_ENGINES } },
1890
+ { $not: [{ $in: [lMedium, PAID_MEDIUMS] }] }
1891
+ ]
1892
+ },
1893
+ then: "Organic Search"
1894
+ },
1895
+ // Organic Social
1896
+ {
1897
+ case: {
1898
+ $and: [
1899
+ { $or: [
1900
+ { $regexMatch: { input: refStr, regex: SOCIAL_NETWORKS } },
1901
+ { $in: [lSource, SOCIAL_SOURCES] }
1902
+ ] },
1903
+ { $not: [{ $in: [lMedium, PAID_MEDIUMS] }] }
1904
+ ]
1905
+ },
1906
+ then: "Organic Social"
1907
+ },
1908
+ // Referral
1909
+ {
1910
+ case: { $and: [{ $ne: [refStr, ""] }, { $gt: [{ $strLenCP: refStr }, 0] }] },
1911
+ then: "Referral"
1912
+ },
1913
+ // Other (has UTM but no referrer)
1914
+ {
1915
+ case: {
1916
+ $or: [
1917
+ { $and: [{ $ne: [{ $ifNull: ["$utm_source", ""] }, ""] }] },
1918
+ { $and: [{ $ne: [{ $ifNull: ["$utm_medium", ""] }, ""] }] },
1919
+ { $and: [{ $ne: [{ $ifNull: ["$utm_campaign", ""] }, ""] }] }
1920
+ ]
1921
+ },
1922
+ then: "Other"
1923
+ }
1924
+ ],
1925
+ default: "Direct"
1926
+ }
1927
+ };
1928
+ }
1523
1929
  function buildFilterMatch(filters) {
1524
1930
  if (!filters) return {};
1525
1931
  const map = {
@@ -1530,6 +1936,10 @@ function buildFilterMatch(filters) {
1530
1936
  "device.type": "device_type",
1531
1937
  "device.browser": "browser",
1532
1938
  "device.os": "os",
1939
+ "device.osVersion": "os_version",
1940
+ "device.deviceModel": "device_model",
1941
+ "device.deviceBrand": "device_brand",
1942
+ "device.appVersion": "app_version",
1533
1943
  "utm.source": "utm_source",
1534
1944
  "utm.medium": "utm_medium",
1535
1945
  "utm.campaign": "utm_campaign",
@@ -1545,7 +1955,9 @@ function buildFilterMatch(filters) {
1545
1955
  };
1546
1956
  const match = {};
1547
1957
  for (const [key, value] of Object.entries(filters)) {
1548
- if (!value || !map[key]) continue;
1958
+ if (!value) continue;
1959
+ if (key === "channel") continue;
1960
+ if (!map[key]) continue;
1549
1961
  match[map[key]] = value;
1550
1962
  }
1551
1963
  return match;
@@ -1614,6 +2026,13 @@ var MongoDBAdapter = class {
1614
2026
  utm_term: e.utm?.term ?? null,
1615
2027
  utm_content: e.utm?.content ?? null,
1616
2028
  ip: e.ip ?? null,
2029
+ os_version: e.device?.osVersion ?? null,
2030
+ device_model: e.device?.deviceModel ?? null,
2031
+ device_brand: e.device?.deviceBrand ?? null,
2032
+ app_version: e.device?.appVersion ?? null,
2033
+ app_build: e.device?.appBuild ?? null,
2034
+ sdk_name: e.device?.sdkName ?? null,
2035
+ sdk_version: e.device?.sdkVersion ?? null,
1617
2036
  created_at: /* @__PURE__ */ new Date()
1618
2037
  }));
1619
2038
  await this.collection.insertMany(docs);
@@ -1627,12 +2046,22 @@ var MongoDBAdapter = class {
1627
2046
  timestamp: { $gte: new Date(dateRange.from), $lte: new Date(dateRange.to) }
1628
2047
  };
1629
2048
  const filterMatch = buildFilterMatch(q.filters);
2049
+ const matchStages = (extra) => {
2050
+ const stages = [
2051
+ { $match: { ...baseMatch, ...filterMatch, ...extra } }
2052
+ ];
2053
+ if (q.filters?.channel) {
2054
+ stages.push({ $addFields: { _channel: channelClassificationSwitch() } });
2055
+ stages.push({ $match: { _channel: q.filters.channel } });
2056
+ }
2057
+ return stages;
2058
+ };
1630
2059
  let data = [];
1631
2060
  let total = 0;
1632
2061
  switch (q.metric) {
1633
2062
  case "pageviews": {
1634
2063
  const [result2] = await this.collection.aggregate([
1635
- { $match: { ...baseMatch, ...filterMatch, type: "pageview" } },
2064
+ ...matchStages({ type: "pageview" }),
1636
2065
  { $count: "count" }
1637
2066
  ]).toArray();
1638
2067
  total = result2?.count ?? 0;
@@ -1641,7 +2070,7 @@ var MongoDBAdapter = class {
1641
2070
  }
1642
2071
  case "visitors": {
1643
2072
  const [result2] = await this.collection.aggregate([
1644
- { $match: { ...baseMatch, ...filterMatch } },
2073
+ ...matchStages(),
1645
2074
  { $group: { _id: "$visitor_id" } },
1646
2075
  { $count: "count" }
1647
2076
  ]).toArray();
@@ -1651,7 +2080,7 @@ var MongoDBAdapter = class {
1651
2080
  }
1652
2081
  case "sessions": {
1653
2082
  const [result2] = await this.collection.aggregate([
1654
- { $match: { ...baseMatch, ...filterMatch } },
2083
+ ...matchStages(),
1655
2084
  { $group: { _id: "$session_id" } },
1656
2085
  { $count: "count" }
1657
2086
  ]).toArray();
@@ -1661,7 +2090,7 @@ var MongoDBAdapter = class {
1661
2090
  }
1662
2091
  case "events": {
1663
2092
  const [result2] = await this.collection.aggregate([
1664
- { $match: { ...baseMatch, ...filterMatch, type: "event" } },
2093
+ ...matchStages({ type: "event" }),
1665
2094
  { $count: "count" }
1666
2095
  ]).toArray();
1667
2096
  total = result2?.count ?? 0;
@@ -1676,7 +2105,7 @@ var MongoDBAdapter = class {
1676
2105
  break;
1677
2106
  }
1678
2107
  const [result2] = await this.collection.aggregate([
1679
- { $match: { ...baseMatch, ...filterMatch, type: "event", event_name: { $in: conversionEvents } } },
2108
+ ...matchStages({ type: "event", event_name: { $in: conversionEvents } }),
1680
2109
  { $count: "count" }
1681
2110
  ]).toArray();
1682
2111
  total = result2?.count ?? 0;
@@ -1685,7 +2114,7 @@ var MongoDBAdapter = class {
1685
2114
  }
1686
2115
  case "top_pages": {
1687
2116
  const rows = await this.collection.aggregate([
1688
- { $match: { ...baseMatch, ...filterMatch, type: "pageview", url: { $ne: null } } },
2117
+ ...matchStages({ type: "pageview", url: { $ne: null } }),
1689
2118
  { $group: { _id: "$url", value: { $sum: 1 } } },
1690
2119
  { $sort: { value: -1 } },
1691
2120
  { $limit: limit }
@@ -1696,7 +2125,7 @@ var MongoDBAdapter = class {
1696
2125
  }
1697
2126
  case "top_referrers": {
1698
2127
  const rows = await this.collection.aggregate([
1699
- { $match: { ...baseMatch, ...filterMatch, type: "pageview", referrer: { $nin: [null, ""] } } },
2128
+ ...matchStages({ type: "pageview", referrer: { $nin: [null, ""] } }),
1700
2129
  { $group: { _id: "$referrer", value: { $sum: 1 } } },
1701
2130
  { $sort: { value: -1 } },
1702
2131
  { $limit: limit }
@@ -1707,7 +2136,7 @@ var MongoDBAdapter = class {
1707
2136
  }
1708
2137
  case "top_countries": {
1709
2138
  const rows = await this.collection.aggregate([
1710
- { $match: { ...baseMatch, ...filterMatch, country: { $ne: null } } },
2139
+ ...matchStages({ country: { $ne: null } }),
1711
2140
  { $group: { _id: "$country", value: { $addToSet: "$visitor_id" } } },
1712
2141
  { $project: { _id: 1, value: { $size: "$value" } } },
1713
2142
  { $sort: { value: -1 } },
@@ -1719,7 +2148,7 @@ var MongoDBAdapter = class {
1719
2148
  }
1720
2149
  case "top_cities": {
1721
2150
  const rows = await this.collection.aggregate([
1722
- { $match: { ...baseMatch, ...filterMatch, city: { $ne: null } } },
2151
+ ...matchStages({ city: { $ne: null } }),
1723
2152
  { $group: { _id: "$city", value: { $addToSet: "$visitor_id" } } },
1724
2153
  { $project: { _id: 1, value: { $size: "$value" } } },
1725
2154
  { $sort: { value: -1 } },
@@ -1731,7 +2160,7 @@ var MongoDBAdapter = class {
1731
2160
  }
1732
2161
  case "top_events": {
1733
2162
  const rows = await this.collection.aggregate([
1734
- { $match: { ...baseMatch, ...filterMatch, type: "event", event_name: { $ne: null } } },
2163
+ ...matchStages({ type: "event", event_name: { $ne: null } }),
1735
2164
  { $group: { _id: "$event_name", value: { $sum: 1 } } },
1736
2165
  { $sort: { value: -1 } },
1737
2166
  { $limit: limit }
@@ -1748,7 +2177,7 @@ var MongoDBAdapter = class {
1748
2177
  break;
1749
2178
  }
1750
2179
  const rows = await this.collection.aggregate([
1751
- { $match: { ...baseMatch, ...filterMatch, type: "event", event_name: { $in: conversionEvents } } },
2180
+ ...matchStages({ type: "event", event_name: { $in: conversionEvents } }),
1752
2181
  { $group: { _id: "$event_name", value: { $sum: 1 } } },
1753
2182
  { $sort: { value: -1 } },
1754
2183
  { $limit: limit }
@@ -1759,7 +2188,7 @@ var MongoDBAdapter = class {
1759
2188
  }
1760
2189
  case "top_exit_pages": {
1761
2190
  const rows = await this.collection.aggregate([
1762
- { $match: { ...baseMatch, ...filterMatch, type: "pageview", url: { $ne: null } } },
2191
+ ...matchStages({ type: "pageview", url: { $ne: null } }),
1763
2192
  { $sort: { timestamp: 1 } },
1764
2193
  { $group: { _id: "$session_id", url: { $last: "$url" } } },
1765
2194
  { $group: { _id: "$url", value: { $sum: 1 } } },
@@ -1772,7 +2201,7 @@ var MongoDBAdapter = class {
1772
2201
  }
1773
2202
  case "top_transitions": {
1774
2203
  const rows = await this.collection.aggregate([
1775
- { $match: { ...baseMatch, ...filterMatch, type: "pageview", url: { $ne: null } } },
2204
+ ...matchStages({ type: "pageview", url: { $ne: null } }),
1776
2205
  {
1777
2206
  $setWindowFields: {
1778
2207
  partitionBy: "$session_id",
@@ -1793,7 +2222,7 @@ var MongoDBAdapter = class {
1793
2222
  }
1794
2223
  case "top_scroll_pages": {
1795
2224
  const rows = await this.collection.aggregate([
1796
- { $match: { ...baseMatch, ...filterMatch, type: "event", event_subtype: "scroll_depth", page_path: { $ne: null } } },
2225
+ ...matchStages({ type: "event", event_subtype: "scroll_depth", page_path: { $ne: null } }),
1797
2226
  { $group: { _id: "$page_path", value: { $sum: 1 } } },
1798
2227
  { $sort: { value: -1 } },
1799
2228
  { $limit: limit }
@@ -1804,15 +2233,11 @@ var MongoDBAdapter = class {
1804
2233
  }
1805
2234
  case "top_button_clicks": {
1806
2235
  const rows = await this.collection.aggregate([
1807
- {
1808
- $match: {
1809
- ...baseMatch,
1810
- ...filterMatch,
1811
- type: "event",
1812
- event_subtype: "button_click",
1813
- $or: [{ element_text: { $ne: null } }, { element_selector: { $ne: null } }]
1814
- }
1815
- },
2236
+ ...matchStages({
2237
+ type: "event",
2238
+ event_subtype: "button_click",
2239
+ $or: [{ element_text: { $ne: null } }, { element_selector: { $ne: null } }]
2240
+ }),
1816
2241
  { $group: { _id: { $ifNull: ["$element_text", "$element_selector"] }, value: { $sum: 1 } } },
1817
2242
  { $sort: { value: -1 } },
1818
2243
  { $limit: limit }
@@ -1823,15 +2248,11 @@ var MongoDBAdapter = class {
1823
2248
  }
1824
2249
  case "top_link_targets": {
1825
2250
  const rows = await this.collection.aggregate([
1826
- {
1827
- $match: {
1828
- ...baseMatch,
1829
- ...filterMatch,
1830
- type: "event",
1831
- event_subtype: { $in: ["link_click", "outbound_click"] },
1832
- target_url_path: { $ne: null }
1833
- }
1834
- },
2251
+ ...matchStages({
2252
+ type: "event",
2253
+ event_subtype: { $in: ["link_click", "outbound_click"] },
2254
+ target_url_path: { $ne: null }
2255
+ }),
1835
2256
  { $group: { _id: "$target_url_path", value: { $sum: 1 } } },
1836
2257
  { $sort: { value: -1 } },
1837
2258
  { $limit: limit }
@@ -1842,7 +2263,7 @@ var MongoDBAdapter = class {
1842
2263
  }
1843
2264
  case "top_devices": {
1844
2265
  const rows = await this.collection.aggregate([
1845
- { $match: { ...baseMatch, ...filterMatch, device_type: { $ne: null } } },
2266
+ ...matchStages({ device_type: { $ne: null } }),
1846
2267
  { $group: { _id: "$device_type", value: { $addToSet: "$visitor_id" } } },
1847
2268
  { $project: { _id: 1, value: { $size: "$value" } } },
1848
2269
  { $sort: { value: -1 } },
@@ -1854,7 +2275,7 @@ var MongoDBAdapter = class {
1854
2275
  }
1855
2276
  case "top_browsers": {
1856
2277
  const rows = await this.collection.aggregate([
1857
- { $match: { ...baseMatch, ...filterMatch, browser: { $ne: null } } },
2278
+ ...matchStages({ browser: { $ne: null } }),
1858
2279
  { $group: { _id: "$browser", value: { $addToSet: "$visitor_id" } } },
1859
2280
  { $project: { _id: 1, value: { $size: "$value" } } },
1860
2281
  { $sort: { value: -1 } },
@@ -1866,7 +2287,7 @@ var MongoDBAdapter = class {
1866
2287
  }
1867
2288
  case "top_os": {
1868
2289
  const rows = await this.collection.aggregate([
1869
- { $match: { ...baseMatch, ...filterMatch, os: { $ne: null } } },
2290
+ ...matchStages({ os: { $ne: null } }),
1870
2291
  { $group: { _id: "$os", value: { $addToSet: "$visitor_id" } } },
1871
2292
  { $project: { _id: 1, value: { $size: "$value" } } },
1872
2293
  { $sort: { value: -1 } },
@@ -1876,10 +2297,47 @@ var MongoDBAdapter = class {
1876
2297
  total = data.reduce((sum, d) => sum + d.value, 0);
1877
2298
  break;
1878
2299
  }
2300
+ case "top_os_versions": {
2301
+ const rows = await this.collection.aggregate([
2302
+ ...matchStages({ os: { $ne: null }, os_version: { $ne: null } }),
2303
+ { $group: { _id: { $concat: ["$os", " ", "$os_version"] }, value: { $addToSet: "$visitor_id" } } },
2304
+ { $project: { _id: 1, value: { $size: "$value" } } },
2305
+ { $sort: { value: -1 } },
2306
+ { $limit: limit }
2307
+ ]).toArray();
2308
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2309
+ total = data.reduce((sum, d) => sum + d.value, 0);
2310
+ break;
2311
+ }
2312
+ case "top_device_models": {
2313
+ const rows = await this.collection.aggregate([
2314
+ ...matchStages({ device_model: { $ne: null } }),
2315
+ { $group: { _id: { $trim: { input: { $concat: [{ $ifNull: ["$device_brand", ""] }, " ", "$device_model"] } } }, value: { $addToSet: "$visitor_id" } } },
2316
+ { $project: { _id: 1, value: { $size: "$value" } } },
2317
+ { $sort: { value: -1 } },
2318
+ { $limit: limit }
2319
+ ]).toArray();
2320
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2321
+ total = data.reduce((sum, d) => sum + d.value, 0);
2322
+ break;
2323
+ }
2324
+ case "top_app_versions": {
2325
+ const rows = await this.collection.aggregate([
2326
+ ...matchStages({ app_version: { $ne: null } }),
2327
+ { $group: { _id: "$app_version", value: { $addToSet: "$visitor_id" } } },
2328
+ { $project: { _id: 1, value: { $size: "$value" } } },
2329
+ { $sort: { value: -1 } },
2330
+ { $limit: limit }
2331
+ ]).toArray();
2332
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2333
+ total = data.reduce((sum, d) => sum + d.value, 0);
2334
+ break;
2335
+ }
1879
2336
  case "top_utm_sources": {
1880
2337
  const rows = await this.collection.aggregate([
1881
- { $match: { ...baseMatch, ...filterMatch, utm_source: { $nin: [null, ""] } } },
1882
- { $group: { _id: "$utm_source", value: { $addToSet: "$visitor_id" } } },
2338
+ ...matchStages({ utm_source: { $nin: [null, ""] } }),
2339
+ { $addFields: { _normalized_source: normalizedUtmSourceSwitch() } },
2340
+ { $group: { _id: "$_normalized_source", value: { $addToSet: "$visitor_id" } } },
1883
2341
  { $project: { _id: 1, value: { $size: "$value" } } },
1884
2342
  { $sort: { value: -1 } },
1885
2343
  { $limit: limit }
@@ -1890,8 +2348,9 @@ var MongoDBAdapter = class {
1890
2348
  }
1891
2349
  case "top_utm_mediums": {
1892
2350
  const rows = await this.collection.aggregate([
1893
- { $match: { ...baseMatch, ...filterMatch, utm_medium: { $nin: [null, ""] } } },
1894
- { $group: { _id: "$utm_medium", value: { $addToSet: "$visitor_id" } } },
2351
+ ...matchStages({ utm_medium: { $nin: [null, ""] } }),
2352
+ { $addFields: { _normalized_medium: normalizedUtmMediumSwitch() } },
2353
+ { $group: { _id: "$_normalized_medium", value: { $addToSet: "$visitor_id" } } },
1895
2354
  { $project: { _id: 1, value: { $size: "$value" } } },
1896
2355
  { $sort: { value: -1 } },
1897
2356
  { $limit: limit }
@@ -1902,7 +2361,7 @@ var MongoDBAdapter = class {
1902
2361
  }
1903
2362
  case "top_utm_campaigns": {
1904
2363
  const rows = await this.collection.aggregate([
1905
- { $match: { ...baseMatch, ...filterMatch, utm_campaign: { $nin: [null, ""] } } },
2364
+ ...matchStages({ utm_campaign: { $nin: [null, ""] } }),
1906
2365
  { $group: { _id: "$utm_campaign", value: { $addToSet: "$visitor_id" } } },
1907
2366
  { $project: { _id: 1, value: { $size: "$value" } } },
1908
2367
  { $sort: { value: -1 } },
@@ -1912,6 +2371,43 @@ var MongoDBAdapter = class {
1912
2371
  total = data.reduce((sum, d) => sum + d.value, 0);
1913
2372
  break;
1914
2373
  }
2374
+ case "top_utm_terms": {
2375
+ const rows = await this.collection.aggregate([
2376
+ ...matchStages({ utm_term: { $nin: [null, ""] } }),
2377
+ { $group: { _id: "$utm_term", value: { $addToSet: "$visitor_id" } } },
2378
+ { $project: { _id: 1, value: { $size: "$value" } } },
2379
+ { $sort: { value: -1 } },
2380
+ { $limit: limit }
2381
+ ]).toArray();
2382
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2383
+ total = data.reduce((sum, d) => sum + d.value, 0);
2384
+ break;
2385
+ }
2386
+ case "top_utm_contents": {
2387
+ const rows = await this.collection.aggregate([
2388
+ ...matchStages({ utm_content: { $nin: [null, ""] } }),
2389
+ { $group: { _id: "$utm_content", value: { $addToSet: "$visitor_id" } } },
2390
+ { $project: { _id: 1, value: { $size: "$value" } } },
2391
+ { $sort: { value: -1 } },
2392
+ { $limit: limit }
2393
+ ]).toArray();
2394
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2395
+ total = data.reduce((sum, d) => sum + d.value, 0);
2396
+ break;
2397
+ }
2398
+ case "top_channels": {
2399
+ const rows = await this.collection.aggregate([
2400
+ ...matchStages(),
2401
+ { $addFields: { _channel: channelClassificationSwitch() } },
2402
+ { $group: { _id: "$_channel", value: { $addToSet: "$visitor_id" } } },
2403
+ { $project: { _id: 1, value: { $size: "$value" } } },
2404
+ { $sort: { value: -1 } },
2405
+ { $limit: limit }
2406
+ ]).toArray();
2407
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2408
+ total = data.reduce((sum, d) => sum + d.value, 0);
2409
+ break;
2410
+ }
1915
2411
  }
1916
2412
  const result = { metric: q.metric, period, data, total };
1917
2413
  if (q.compare && ["pageviews", "visitors", "sessions", "events", "conversions"].includes(q.metric)) {
@@ -2374,7 +2870,18 @@ var MongoDBAdapter = class {
2374
2870
  userId: doc.user_id ?? void 0,
2375
2871
  traits: doc.traits ?? void 0,
2376
2872
  geo: doc.country ? { country: doc.country, city: doc.city ?? void 0, region: doc.region ?? void 0 } : void 0,
2377
- device: doc.device_type ? { type: doc.device_type, browser: doc.browser ?? "", os: doc.os ?? "" } : void 0,
2873
+ device: doc.device_type ? {
2874
+ type: doc.device_type,
2875
+ browser: doc.browser ?? "",
2876
+ os: doc.os ?? "",
2877
+ osVersion: doc.os_version ?? void 0,
2878
+ deviceModel: doc.device_model ?? void 0,
2879
+ deviceBrand: doc.device_brand ?? void 0,
2880
+ appVersion: doc.app_version ?? void 0,
2881
+ appBuild: doc.app_build ?? void 0,
2882
+ sdkName: doc.sdk_name ?? void 0,
2883
+ sdkVersion: doc.sdk_version ?? void 0
2884
+ } : void 0,
2378
2885
  language: doc.language ?? void 0,
2379
2886
  utm: doc.utm_source ? {
2380
2887
  source: doc.utm_source ?? void 0,
@@ -2392,6 +2899,7 @@ var MongoDBAdapter = class {
2392
2899
  site_id: generateSiteId(),
2393
2900
  secret_key: generateSecretKey(),
2394
2901
  name: data.name,
2902
+ type: data.type ?? "web",
2395
2903
  domain: data.domain ?? null,
2396
2904
  allowed_origins: data.allowedOrigins ?? null,
2397
2905
  conversion_events: data.conversionEvents ?? null,
@@ -2416,6 +2924,7 @@ var MongoDBAdapter = class {
2416
2924
  async updateSite(siteId, data) {
2417
2925
  const updates = { updated_at: /* @__PURE__ */ new Date() };
2418
2926
  if (data.name !== void 0) updates.name = data.name;
2927
+ if (data.type !== void 0) updates.type = data.type;
2419
2928
  if (data.domain !== void 0) updates.domain = data.domain || null;
2420
2929
  if (data.allowedOrigins !== void 0) updates.allowed_origins = data.allowedOrigins.length > 0 ? data.allowedOrigins : null;
2421
2930
  if (data.conversionEvents !== void 0) updates.conversion_events = data.conversionEvents.length > 0 ? data.conversionEvents : null;
@@ -2447,6 +2956,7 @@ var MongoDBAdapter = class {
2447
2956
  siteId: doc.site_id,
2448
2957
  secretKey: doc.secret_key,
2449
2958
  name: doc.name,
2959
+ type: doc.type ?? "web",
2450
2960
  domain: doc.domain ?? void 0,
2451
2961
  allowedOrigins: doc.allowed_origins ?? void 0,
2452
2962
  conversionEvents: doc.conversion_events ?? void 0,
@@ -2715,9 +3225,26 @@ async function createCollector(config) {
2715
3225
  return false;
2716
3226
  }
2717
3227
  function enrichEvents(events, ip, userAgent) {
2718
- const device = parseUserAgent(userAgent);
3228
+ const uaDevice = parseUserAgent(userAgent);
2719
3229
  return events.map((event) => {
2720
3230
  const geo = resolveGeo(ip, event.timezone);
3231
+ let device;
3232
+ if (event.mobile?.platform) {
3233
+ device = {
3234
+ type: "mobile",
3235
+ browser: "App",
3236
+ os: event.mobile.platform === "ios" ? "iOS" : "Android",
3237
+ osVersion: event.mobile.osVersion,
3238
+ deviceModel: event.mobile.deviceModel,
3239
+ deviceBrand: event.mobile.deviceBrand,
3240
+ appVersion: event.mobile.appVersion,
3241
+ appBuild: event.mobile.appBuild,
3242
+ sdkName: event.mobile.sdkName,
3243
+ sdkVersion: event.mobile.sdkVersion
3244
+ };
3245
+ } else {
3246
+ device = uaDevice;
3247
+ }
2721
3248
  return { ...event, ip, geo, device };
2722
3249
  });
2723
3250
  }
@@ -2777,6 +3304,22 @@ async function createCollector(config) {
2777
3304
  }
2778
3305
  return req.ip || req.socket?.remoteAddress || req.connection?.remoteAddress || "";
2779
3306
  }
3307
+ function extractRequestHostname(req) {
3308
+ const headerValue = (value) => {
3309
+ if (Array.isArray(value)) return value[0];
3310
+ if (typeof value === "string") return value;
3311
+ return void 0;
3312
+ };
3313
+ const origin = headerValue(req.headers?.origin);
3314
+ const referer = headerValue(req.headers?.referer) || headerValue(req.headers?.referrer);
3315
+ const raw = origin ?? referer;
3316
+ if (!raw || raw === "null") return void 0;
3317
+ try {
3318
+ return new URL(raw).hostname.toLowerCase();
3319
+ } catch {
3320
+ return void 0;
3321
+ }
3322
+ }
2780
3323
  function handler() {
2781
3324
  return async (req, res) => {
2782
3325
  res.setHeader?.("Access-Control-Allow-Origin", "*");
@@ -2802,6 +3345,12 @@ async function createCollector(config) {
2802
3345
  sendJson(res, 400, { ok: false, error: "Too many events (max 100)" });
2803
3346
  return;
2804
3347
  }
3348
+ const siteIds = new Set(payload.events.map((event) => event.siteId).filter(Boolean));
3349
+ if (siteIds.size !== 1) {
3350
+ sendJson(res, 200, { ok: true });
3351
+ return;
3352
+ }
3353
+ const siteId = Array.from(siteIds)[0];
2805
3354
  const userAgent = req.headers?.["user-agent"] || "";
2806
3355
  if (isBot(userAgent)) {
2807
3356
  sendJson(res, 200, { ok: true });
@@ -2809,26 +3358,15 @@ async function createCollector(config) {
2809
3358
  }
2810
3359
  const ip = extractIp(req);
2811
3360
  const enriched = enrichEvents(payload.events, ip, userAgent);
2812
- const siteId = enriched[0]?.siteId;
2813
- if (siteId) {
2814
- const site = await db.getSite(siteId);
2815
- if (site?.allowedOrigins && site.allowedOrigins.length > 0) {
2816
- const allowed = new Set(site.allowedOrigins.map((h) => h.toLowerCase()));
2817
- const filtered = enriched.filter((event) => {
2818
- if (!event.url) return true;
2819
- try {
2820
- const hostname = new URL(event.url).hostname.toLowerCase();
2821
- return allowed.has(hostname);
2822
- } catch {
2823
- return true;
2824
- }
2825
- });
2826
- if (filtered.length === 0) {
2827
- sendJson(res, 200, { ok: true });
2828
- return;
2829
- }
2830
- await processIdentity(filtered);
2831
- await db.insertEvents(filtered);
3361
+ const site = await db.getSite(siteId);
3362
+ if (site?.allowedOrigins && site.allowedOrigins.length > 0) {
3363
+ const requestHostname = extractRequestHostname(req);
3364
+ if (!requestHostname) {
3365
+ sendJson(res, 200, { ok: true });
3366
+ return;
3367
+ }
3368
+ const allowed = new Set(site.allowedOrigins.map((h) => h.toLowerCase()));
3369
+ if (!allowed.has(requestHostname)) {
2832
3370
  sendJson(res, 200, { ok: true });
2833
3371
  return;
2834
3372
  }
@@ -2868,7 +3406,8 @@ async function createCollector(config) {
2868
3406
  dateFrom: params.dateFrom,
2869
3407
  dateTo: params.dateTo,
2870
3408
  granularity: q.granularity,
2871
- filters: q.filters ? JSON.parse(q.filters) : void 0
3409
+ filters: q.filters ? JSON.parse(q.filters) : void 0,
3410
+ timezone: params.timezone
2872
3411
  };
2873
3412
  if (tsParams.metric === "conversions") {
2874
3413
  const site = await db.getSite(params.siteId);
@@ -3170,19 +3709,6 @@ async function parseBody(req) {
3170
3709
  req.on("error", reject);
3171
3710
  });
3172
3711
  }
3173
- function extractQueryParams(req) {
3174
- const q = req.query ?? Object.fromEntries(new URL(req.url, "http://localhost").searchParams);
3175
- return {
3176
- siteId: q.siteId,
3177
- metric: q.metric,
3178
- period: q.period,
3179
- dateFrom: q.dateFrom,
3180
- dateTo: q.dateTo,
3181
- limit: q.limit ? parseInt(q.limit, 10) : void 0,
3182
- filters: q.filters ? JSON.parse(q.filters) : void 0,
3183
- compare: q.compare === "true" || q.compare === "1"
3184
- };
3185
- }
3186
3712
  function sendJson(res, status, body) {
3187
3713
  if (typeof res.status === "function" && typeof res.json === "function") {
3188
3714
  res.status(status).json(body);