mcp-scraper 0.47.0 → 0.49.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.
Files changed (59) hide show
  1. package/README.md +2 -2
  2. package/dist/analytics-repository-WWXVU3VW.js +119 -0
  3. package/dist/bin/api-server.cjs +7905 -1911
  4. package/dist/bin/api-server.cjs.map +1 -1
  5. package/dist/bin/api-server.js +8 -4
  6. package/dist/bin/api-server.js.map +1 -1
  7. package/dist/bin/mcp-scraper-cli.cjs +1 -1
  8. package/dist/bin/mcp-scraper-cli.cjs.map +1 -1
  9. package/dist/bin/mcp-scraper-cli.js +1 -1
  10. package/dist/bin/mcp-scraper-install.cjs +3 -3
  11. package/dist/bin/mcp-scraper-install.cjs.map +1 -1
  12. package/dist/bin/mcp-scraper-install.js +2 -2
  13. package/dist/bin/mcp-stdio-server.cjs +1856 -1029
  14. package/dist/bin/mcp-stdio-server.cjs.map +1 -1
  15. package/dist/bin/mcp-stdio-server.js +5 -5
  16. package/dist/bin/paa-harvest.cjs.map +1 -1
  17. package/dist/bin/paa-harvest.js +3 -3
  18. package/dist/{chunk-XCGQBGDA.js → chunk-64WBUDPC.js} +2125 -1262
  19. package/dist/chunk-64WBUDPC.js.map +1 -0
  20. package/dist/{chunk-MW3ET532.js → chunk-6DPIE262.js} +2 -2
  21. package/dist/{chunk-JH7A4HA7.js → chunk-MFE6RMIA.js} +2 -2
  22. package/dist/{chunk-5S6LYQQO.js → chunk-MFNGUM4L.js} +2 -2
  23. package/dist/chunk-MFNGUM4L.js.map +1 -0
  24. package/dist/{chunk-IWYYZXBR.js → chunk-PODASLGT.js} +2 -2
  25. package/dist/{chunk-QSUGPYGQ.js → chunk-QH63IZTX.js} +3 -3
  26. package/dist/chunk-QH63IZTX.js.map +1 -0
  27. package/dist/{chunk-TV6KPZ6J.js → chunk-R66PJOZW.js} +2 -2
  28. package/dist/chunk-XLEDHV3J.js +7 -0
  29. package/dist/chunk-XLEDHV3J.js.map +1 -0
  30. package/dist/chunk-YW2LXLDU.js +2865 -0
  31. package/dist/chunk-YW2LXLDU.js.map +1 -0
  32. package/dist/{chunk-GV5EGE5M.js → chunk-ZEMHL36J.js} +2 -2
  33. package/dist/{db-W3RPRILW.js → db-A4YUPV4Z.js} +2 -2
  34. package/dist/{extract-bundle-DXWWQER6.js → extract-bundle-2HONVC5T.js} +3 -3
  35. package/dist/index.cjs.map +1 -1
  36. package/dist/index.js +3 -3
  37. package/dist/{location-data-repository-2FJVMTZ2.js → location-data-repository-YWFJGNIP.js} +3 -3
  38. package/dist/{server-OHMZJRSB.js → server-JE7SLZLJ.js} +2605 -346
  39. package/dist/server-JE7SLZLJ.js.map +1 -0
  40. package/dist/{site-extract-repository-UULQAGO4.js → site-extract-repository-STNOI5OH.js} +3 -3
  41. package/dist/site-extract-repository-STNOI5OH.js.map +1 -0
  42. package/dist/{worker-3QX7LG62.js → worker-4MHZS3OB.js} +5 -5
  43. package/package.json +3 -1
  44. package/dist/chunk-5S6LYQQO.js.map +0 -1
  45. package/dist/chunk-H4NWAF5B.js +0 -7
  46. package/dist/chunk-H4NWAF5B.js.map +0 -1
  47. package/dist/chunk-QSUGPYGQ.js.map +0 -1
  48. package/dist/chunk-XCGQBGDA.js.map +0 -1
  49. package/dist/server-OHMZJRSB.js.map +0 -1
  50. /package/dist/{db-W3RPRILW.js.map → analytics-repository-WWXVU3VW.js.map} +0 -0
  51. /package/dist/{chunk-MW3ET532.js.map → chunk-6DPIE262.js.map} +0 -0
  52. /package/dist/{chunk-JH7A4HA7.js.map → chunk-MFE6RMIA.js.map} +0 -0
  53. /package/dist/{chunk-IWYYZXBR.js.map → chunk-PODASLGT.js.map} +0 -0
  54. /package/dist/{chunk-TV6KPZ6J.js.map → chunk-R66PJOZW.js.map} +0 -0
  55. /package/dist/{chunk-GV5EGE5M.js.map → chunk-ZEMHL36J.js.map} +0 -0
  56. /package/dist/{location-data-repository-2FJVMTZ2.js.map → db-A4YUPV4Z.js.map} +0 -0
  57. /package/dist/{extract-bundle-DXWWQER6.js.map → extract-bundle-2HONVC5T.js.map} +0 -0
  58. /package/dist/{site-extract-repository-UULQAGO4.js.map → location-data-repository-YWFJGNIP.js.map} +0 -0
  59. /package/dist/{worker-3QX7LG62.js.map → worker-4MHZS3OB.js.map} +0 -0
@@ -0,0 +1,2865 @@
1
+ // src/api/analytics-repository.ts
2
+ import { createHash, createHmac as createHmac2, randomBytes, randomUUID } from "crypto";
3
+ import { Pool } from "pg";
4
+
5
+ // src/api/session.ts
6
+ import { createHmac, timingSafeEqual } from "crypto";
7
+ var isProduction = () => process.env.NODE_ENV === "production" || process.env.VERCEL === "1";
8
+ function getSessionSecret() {
9
+ const configured = process.env.SESSION_SECRET?.trim();
10
+ if (configured) return configured;
11
+ if (isProduction()) throw new Error("SESSION_SECRET is not set \u2014 add it to your Vercel env vars (see src/api/env.ts)");
12
+ return "dev-secret-change-me";
13
+ }
14
+ var secret = () => getSessionSecret();
15
+ function safeEqualHex(a, b) {
16
+ if (a.length !== b.length) return false;
17
+ try {
18
+ return timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+ function signSession(userId) {
24
+ const payload = String(userId);
25
+ const sig = createHmac("sha256", secret()).update(payload).digest("hex");
26
+ return `${payload}.${sig}`;
27
+ }
28
+ function verifySession(token) {
29
+ const dot = token.lastIndexOf(".");
30
+ if (dot === -1) return null;
31
+ const payload = token.slice(0, dot);
32
+ const sig = token.slice(dot + 1);
33
+ const expected = createHmac("sha256", secret()).update(payload).digest("hex");
34
+ if (!safeEqualHex(sig, expected)) return null;
35
+ const id = parseInt(payload);
36
+ return isNaN(id) ? null : id;
37
+ }
38
+
39
+ // src/api/analytics-attribution.ts
40
+ var aliases = [
41
+ [/perplexity/i, "llm", "Perplexity"],
42
+ [/chatgpt|chat\.openai|openai/i, "llm", "ChatGPT"],
43
+ [/claude|anthropic/i, "llm", "Claude"],
44
+ [/\bgrok\b|xai/i, "llm", "Grok"],
45
+ [/ai[ _-]?overview|google[ _-]?ai|\bsge\b|gemini/i, "llm", "Google AI Overview"],
46
+ [/instagram/i, "social", "Instagram"],
47
+ [/facebook|\bfb\b|meta/i, "social", "Facebook"],
48
+ [/linkedin/i, "social", "LinkedIn"],
49
+ [/tiktok/i, "social", "TikTok"],
50
+ [/youtube|youtu\.be/i, "social", "YouTube"],
51
+ [/twitter|x\.com/i, "social", "X"],
52
+ [/\bg2\b|g2\.com/i, "review", "G2"],
53
+ [/trustpilot/i, "review", "Trustpilot"],
54
+ [/\bbbb\b|better business bureau/i, "review", "BBB"],
55
+ [/capterra/i, "review", "Capterra"],
56
+ [/yelp/i, "review", "Yelp"],
57
+ [/google|bing|duckduckgo|yahoo/i, "search", "Search"]
58
+ ];
59
+ function clean(value, max = 240) {
60
+ return typeof value === "string" && value.trim() ? value.trim().slice(0, max) : null;
61
+ }
62
+ function referrerHost(referrer) {
63
+ try {
64
+ return referrer ? new URL(referrer).hostname.replace(/^www\./, "") : "";
65
+ } catch {
66
+ return "";
67
+ }
68
+ }
69
+ function resolveAnalyticsAttribution(input) {
70
+ const properties = input.properties ?? {};
71
+ const source = clean(input.source, 180) || referrerHost(input.referrer) || "(direct)";
72
+ const medium = clean(input.medium, 180) || (source === "(direct)" ? "(none)" : "referral");
73
+ const clickIds = input.clickIds ?? {};
74
+ const haystack = `${source} ${medium} ${referrerHost(input.referrer)}`;
75
+ let channelFamily = source === "(direct)" ? "direct" : /email|newsletter/i.test(medium) ? "email" : "referral";
76
+ let platform = source === "(direct)" ? "Direct" : source;
77
+ for (const [pattern, family, label] of aliases) if (pattern.test(haystack)) {
78
+ channelFamily = family;
79
+ platform = label;
80
+ break;
81
+ }
82
+ if (clickIds.fbclid) {
83
+ channelFamily = "social";
84
+ platform = /instagram/i.test(haystack) ? "Instagram" : "Facebook";
85
+ } else if (clickIds.ttclid) {
86
+ channelFamily = "social";
87
+ platform = "TikTok";
88
+ } else if (clickIds.rdt_cid) {
89
+ channelFamily = "social";
90
+ platform = "Reddit";
91
+ } else if (clickIds.gclid || clickIds.gbraid || clickIds.wbraid) {
92
+ channelFamily = "search";
93
+ platform = "Google";
94
+ } else if (clickIds.msclkid) {
95
+ channelFamily = "search";
96
+ platform = "Microsoft Ads";
97
+ }
98
+ return {
99
+ channelFamily,
100
+ platform,
101
+ source,
102
+ medium,
103
+ campaign: clean(input.campaign),
104
+ campaignId: clean(properties.campaign_id ?? properties.utm_id),
105
+ adSetId: clean(properties.adset_id ?? properties.ad_set_id ?? properties.adgroup_id ?? properties.ad_group_id),
106
+ adId: clean(properties.ad_id),
107
+ creativeId: clean(properties.creative_id),
108
+ placement: clean(properties.placement),
109
+ term: clean(properties.utm_term),
110
+ content: clean(properties.utm_content)
111
+ };
112
+ }
113
+
114
+ // src/api/analytics-repository.ts
115
+ var AnalyticsRepositoryError = class extends Error {
116
+ constructor(code, message, status = 400) {
117
+ super(message);
118
+ this.code = code;
119
+ this.status = status;
120
+ this.name = "AnalyticsRepositoryError";
121
+ }
122
+ code;
123
+ status;
124
+ };
125
+ var pool = null;
126
+ function databaseUrl() {
127
+ const value = process.env.ANALYTICS_DATABASE_URL?.trim();
128
+ if (!value) {
129
+ throw new AnalyticsRepositoryError(
130
+ "analytics_database_unavailable",
131
+ "Site Analytics is not configured.",
132
+ 503
133
+ );
134
+ }
135
+ return value;
136
+ }
137
+ function getAnalyticsPool() {
138
+ if (!pool) pool = new Pool({ connectionString: databaseUrl(), max: 8 });
139
+ return pool;
140
+ }
141
+ async function closeAnalyticsPool() {
142
+ if (!pool) return;
143
+ const current = pool;
144
+ pool = null;
145
+ await current.end();
146
+ }
147
+ async function migrateAnalytics() {
148
+ const db = getAnalyticsPool();
149
+ const client = await db.connect();
150
+ await client.query("BEGIN");
151
+ try {
152
+ await client.query(`
153
+ CREATE TABLE IF NOT EXISTS analytics_schema_migrations (
154
+ version text PRIMARY KEY,
155
+ applied_at timestamptz NOT NULL DEFAULT now()
156
+ )
157
+ `);
158
+ await client.query(`
159
+ CREATE TABLE IF NOT EXISTS analytics_sites (
160
+ id uuid PRIMARY KEY,
161
+ slug text NOT NULL,
162
+ name text NOT NULL,
163
+ owner_user_id bigint NOT NULL,
164
+ timezone text NOT NULL DEFAULT 'UTC',
165
+ status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
166
+ created_at timestamptz NOT NULL DEFAULT now(),
167
+ updated_at timestamptz NOT NULL DEFAULT now(),
168
+ UNIQUE(owner_user_id, slug)
169
+ )
170
+ `);
171
+ await client.query(`
172
+ CREATE TABLE IF NOT EXISTS analytics_site_grants (
173
+ site_id uuid NOT NULL REFERENCES analytics_sites(id) ON DELETE CASCADE,
174
+ user_id bigint NOT NULL,
175
+ role text NOT NULL CHECK (role IN ('viewer', 'editor', 'owner')),
176
+ created_at timestamptz NOT NULL DEFAULT now(),
177
+ PRIMARY KEY(site_id, user_id)
178
+ )
179
+ `);
180
+ await client.query(`
181
+ CREATE TABLE IF NOT EXISTS analytics_pixels (
182
+ id uuid PRIMARY KEY,
183
+ site_id uuid NOT NULL REFERENCES analytics_sites(id) ON DELETE CASCADE,
184
+ public_id text NOT NULL UNIQUE,
185
+ name text NOT NULL,
186
+ environment text NOT NULL DEFAULT 'production',
187
+ status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
188
+ created_at timestamptz NOT NULL DEFAULT now(),
189
+ updated_at timestamptz NOT NULL DEFAULT now(),
190
+ disabled_at timestamptz
191
+ )
192
+ `);
193
+ await client.query(`
194
+ CREATE TABLE IF NOT EXISTS analytics_pixel_domains (
195
+ id uuid PRIMARY KEY,
196
+ pixel_id uuid NOT NULL REFERENCES analytics_pixels(id) ON DELETE CASCADE,
197
+ hostname text NOT NULL,
198
+ state text NOT NULL DEFAULT 'observed_unverified'
199
+ CHECK (state IN ('observed_unverified', 'approved', 'rejected', 'revoked')),
200
+ first_seen_at timestamptz NOT NULL DEFAULT now(),
201
+ last_seen_at timestamptz NOT NULL DEFAULT now(),
202
+ accepted_count bigint NOT NULL DEFAULT 0,
203
+ rejected_count bigint NOT NULL DEFAULT 0,
204
+ updated_at timestamptz NOT NULL DEFAULT now(),
205
+ UNIQUE(pixel_id, hostname)
206
+ )
207
+ `);
208
+ await client.query(`
209
+ CREATE TABLE IF NOT EXISTS analytics_events (
210
+ id uuid PRIMARY KEY,
211
+ event_id text NOT NULL,
212
+ site_id uuid NOT NULL REFERENCES analytics_sites(id) ON DELETE CASCADE,
213
+ pixel_id uuid NOT NULL REFERENCES analytics_pixels(id) ON DELETE CASCADE,
214
+ hostname text NOT NULL,
215
+ visitor_id text,
216
+ session_id text,
217
+ event_name text NOT NULL,
218
+ occurred_at timestamptz NOT NULL,
219
+ received_at timestamptz NOT NULL DEFAULT now(),
220
+ path text NOT NULL DEFAULT '/',
221
+ canonical_url text,
222
+ title text,
223
+ referrer text,
224
+ source text,
225
+ medium text,
226
+ campaign text,
227
+ device_class text,
228
+ properties jsonb NOT NULL DEFAULT '{}'::jsonb,
229
+ bot_status text NOT NULL DEFAULT 'unknown',
230
+ UNIQUE(site_id, event_id)
231
+ )
232
+ `);
233
+ await client.query(`
234
+ CREATE TABLE IF NOT EXISTS analytics_conversions (
235
+ id uuid PRIMARY KEY,
236
+ source_event_id text NOT NULL,
237
+ site_id uuid NOT NULL REFERENCES analytics_sites(id) ON DELETE CASCADE,
238
+ pixel_id uuid REFERENCES analytics_pixels(id) ON DELETE SET NULL,
239
+ session_id text,
240
+ conversion_kind text NOT NULL,
241
+ value_minor bigint NOT NULL DEFAULT 0,
242
+ currency text NOT NULL DEFAULT 'USD',
243
+ occurred_at timestamptz NOT NULL,
244
+ created_at timestamptz NOT NULL DEFAULT now(),
245
+ UNIQUE(site_id, source_event_id)
246
+ )
247
+ `);
248
+ await client.query(`
249
+ CREATE TABLE IF NOT EXISTS analytics_ingestion_receipts (
250
+ id uuid PRIMARY KEY,
251
+ request_id text NOT NULL UNIQUE,
252
+ site_id uuid REFERENCES analytics_sites(id) ON DELETE SET NULL,
253
+ pixel_id uuid REFERENCES analytics_pixels(id) ON DELETE SET NULL,
254
+ hostname text,
255
+ accepted_count integer NOT NULL DEFAULT 0,
256
+ rejected_count integer NOT NULL DEFAULT 0,
257
+ reason_codes jsonb NOT NULL DEFAULT '[]'::jsonb,
258
+ created_at timestamptz NOT NULL DEFAULT now()
259
+ )
260
+ `);
261
+ await client.query(`
262
+ CREATE TABLE IF NOT EXISTS analytics_rollups_daily (
263
+ site_id uuid NOT NULL REFERENCES analytics_sites(id) ON DELETE CASCADE,
264
+ pixel_id uuid NOT NULL REFERENCES analytics_pixels(id) ON DELETE CASCADE,
265
+ hostname text NOT NULL,
266
+ bucket date NOT NULL,
267
+ source text NOT NULL DEFAULT '(direct)',
268
+ medium text NOT NULL DEFAULT '(none)',
269
+ campaign text NOT NULL DEFAULT '(none)',
270
+ pageviews integer NOT NULL DEFAULT 0,
271
+ events integer NOT NULL DEFAULT 0,
272
+ visitors integer NOT NULL DEFAULT 0,
273
+ sessions integer NOT NULL DEFAULT 0,
274
+ refreshed_at timestamptz NOT NULL DEFAULT now(),
275
+ PRIMARY KEY(site_id, pixel_id, hostname, bucket, source, medium, campaign)
276
+ )
277
+ `);
278
+ await client.query(`
279
+ CREATE TABLE IF NOT EXISTS analytics_rollup_runs (
280
+ id uuid PRIMARY KEY,
281
+ window_start timestamptz NOT NULL,
282
+ window_end timestamptz NOT NULL,
283
+ status text NOT NULL CHECK (status IN ('running', 'complete', 'failed')),
284
+ row_count integer NOT NULL DEFAULT 0,
285
+ started_at timestamptz NOT NULL DEFAULT now(),
286
+ completed_at timestamptz,
287
+ error_code text
288
+ )
289
+ `);
290
+ await client.query(`
291
+ CREATE TABLE IF NOT EXISTS analytics_exports (
292
+ id uuid PRIMARY KEY,
293
+ site_id uuid NOT NULL REFERENCES analytics_sites(id) ON DELETE CASCADE,
294
+ requested_by_user_id bigint NOT NULL,
295
+ idempotency_key text,
296
+ report text NOT NULL,
297
+ format text NOT NULL CHECK (format IN ('csv', 'json', 'markdown')),
298
+ filters jsonb NOT NULL,
299
+ content text NOT NULL,
300
+ created_at timestamptz NOT NULL DEFAULT now()
301
+ )
302
+ `);
303
+ await client.query(`
304
+ CREATE TABLE IF NOT EXISTS analytics_campaign_links (
305
+ id uuid PRIMARY KEY,
306
+ site_id uuid NOT NULL REFERENCES analytics_sites(id) ON DELETE CASCADE,
307
+ pixel_id uuid REFERENCES analytics_pixels(id) ON DELETE SET NULL,
308
+ name text NOT NULL,
309
+ destination_url text NOT NULL,
310
+ short_code text NOT NULL UNIQUE,
311
+ utm_source text NOT NULL,
312
+ utm_medium text NOT NULL,
313
+ utm_campaign text NOT NULL,
314
+ utm_term text,
315
+ utm_content text,
316
+ ad_group text,
317
+ ad_name text,
318
+ creative_id text,
319
+ status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')),
320
+ created_by_user_id bigint NOT NULL,
321
+ created_at timestamptz NOT NULL DEFAULT now(),
322
+ updated_at timestamptz NOT NULL DEFAULT now()
323
+ )
324
+ `);
325
+ await client.query(`
326
+ CREATE TABLE IF NOT EXISTS analytics_campaign_clicks (
327
+ id uuid PRIMARY KEY,
328
+ link_id uuid NOT NULL REFERENCES analytics_campaign_links(id) ON DELETE CASCADE,
329
+ occurred_at timestamptz NOT NULL DEFAULT now(),
330
+ referrer text
331
+ )
332
+ `);
333
+ await client.query(`
334
+ CREATE TABLE IF NOT EXISTS analytics_forms (
335
+ id uuid PRIMARY KEY,
336
+ public_id text NOT NULL UNIQUE,
337
+ site_id uuid NOT NULL REFERENCES analytics_sites(id) ON DELETE CASCADE,
338
+ pixel_id uuid NOT NULL REFERENCES analytics_pixels(id) ON DELETE RESTRICT,
339
+ name text NOT NULL,
340
+ slug text NOT NULL,
341
+ fields jsonb NOT NULL,
342
+ brand jsonb NOT NULL,
343
+ submit_label text NOT NULL DEFAULT 'Submit',
344
+ success_message text NOT NULL DEFAULT 'Thank you. Your response was received.',
345
+ redirect_url text,
346
+ consent_text text,
347
+ status text NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'archived')),
348
+ version integer NOT NULL DEFAULT 1,
349
+ created_by_user_id bigint NOT NULL,
350
+ created_at timestamptz NOT NULL DEFAULT now(),
351
+ updated_at timestamptz NOT NULL DEFAULT now(),
352
+ UNIQUE(site_id, slug)
353
+ )
354
+ `);
355
+ await client.query(`
356
+ CREATE TABLE IF NOT EXISTS analytics_form_submissions (
357
+ id uuid PRIMARY KEY,
358
+ form_id uuid NOT NULL REFERENCES analytics_forms(id) ON DELETE CASCADE,
359
+ site_id uuid NOT NULL REFERENCES analytics_sites(id) ON DELETE CASCADE,
360
+ pixel_id uuid NOT NULL REFERENCES analytics_pixels(id) ON DELETE RESTRICT,
361
+ visitor_id text,
362
+ session_id text,
363
+ crm_person_ref text,
364
+ source text,
365
+ medium text,
366
+ campaign text,
367
+ occurred_at timestamptz NOT NULL DEFAULT now()
368
+ )
369
+ `);
370
+ await client.query(
371
+ `ALTER TABLE analytics_form_submissions ADD COLUMN IF NOT EXISTS crm_delivery_status text NOT NULL DEFAULT 'pending'`
372
+ );
373
+ await client.query(`
374
+ CREATE TABLE IF NOT EXISTS analytics_form_delivery_jobs (
375
+ id uuid PRIMARY KEY,
376
+ submission_id uuid NOT NULL UNIQUE REFERENCES analytics_form_submissions(id) ON DELETE CASCADE,
377
+ owner_user_id bigint NOT NULL,
378
+ payload_ciphertext text NOT NULL,
379
+ status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'delivered', 'failed', 'expired')),
380
+ attempts integer NOT NULL DEFAULT 0,
381
+ last_error_code text,
382
+ next_attempt_at timestamptz NOT NULL DEFAULT now(),
383
+ expires_at timestamptz NOT NULL DEFAULT now() + interval '7 days',
384
+ created_at timestamptz NOT NULL DEFAULT now(),
385
+ delivered_at timestamptz
386
+ )
387
+ `);
388
+ await client.query(`
389
+ CREATE TABLE IF NOT EXISTS analytics_people (
390
+ id uuid PRIMARY KEY,
391
+ site_id uuid NOT NULL REFERENCES analytics_sites(id) ON DELETE CASCADE,
392
+ crm_person_ref text NOT NULL,
393
+ first_seen_at timestamptz NOT NULL DEFAULT now(),
394
+ last_seen_at timestamptz NOT NULL DEFAULT now(),
395
+ UNIQUE(site_id, crm_person_ref)
396
+ )
397
+ `);
398
+ await client.query(`
399
+ CREATE TABLE IF NOT EXISTS analytics_identity_nodes (
400
+ id uuid PRIMARY KEY,
401
+ site_id uuid NOT NULL REFERENCES analytics_sites(id) ON DELETE CASCADE,
402
+ kind text NOT NULL CHECK (kind IN ('visitor_id','session_id','email','phone','customer_id','click_id','device_signal','network_signal')),
403
+ value_hmac text NOT NULL,
404
+ first_seen_at timestamptz NOT NULL DEFAULT now(),
405
+ last_seen_at timestamptz NOT NULL DEFAULT now(),
406
+ UNIQUE(site_id, kind, value_hmac)
407
+ )
408
+ `);
409
+ await client.query(`
410
+ CREATE TABLE IF NOT EXISTS analytics_identity_edges (
411
+ id uuid PRIMARY KEY,
412
+ site_id uuid NOT NULL REFERENCES analytics_sites(id) ON DELETE CASCADE,
413
+ person_id uuid NOT NULL REFERENCES analytics_people(id) ON DELETE CASCADE,
414
+ identity_node_id uuid NOT NULL REFERENCES analytics_identity_nodes(id) ON DELETE CASCADE,
415
+ evidence_kind text NOT NULL,
416
+ confidence numeric(4,3) NOT NULL CHECK (confidence >= 0 AND confidence <= 1),
417
+ consent_scope text NOT NULL DEFAULT 'analytics',
418
+ first_seen_at timestamptz NOT NULL DEFAULT now(),
419
+ last_seen_at timestamptz NOT NULL DEFAULT now(),
420
+ UNIQUE(person_id, identity_node_id, evidence_kind)
421
+ )
422
+ `);
423
+ await client.query(
424
+ `ALTER TABLE analytics_events ADD COLUMN IF NOT EXISTS click_ids jsonb NOT NULL DEFAULT '{}'::jsonb`
425
+ );
426
+ await client.query(
427
+ `ALTER TABLE analytics_form_submissions ADD COLUMN IF NOT EXISTS click_ids jsonb NOT NULL DEFAULT '{}'::jsonb`
428
+ );
429
+ await client.query(
430
+ `ALTER TABLE analytics_form_submissions ADD COLUMN IF NOT EXISTS person_id uuid REFERENCES analytics_people(id) ON DELETE SET NULL`
431
+ );
432
+ await client.query(
433
+ `ALTER TABLE analytics_events ADD COLUMN IF NOT EXISTS person_id uuid REFERENCES analytics_people(id) ON DELETE SET NULL`
434
+ );
435
+ await client.query(
436
+ `ALTER TABLE analytics_events ADD COLUMN IF NOT EXISTS channel_family text`
437
+ );
438
+ await client.query(
439
+ `ALTER TABLE analytics_events ADD COLUMN IF NOT EXISTS platform text`
440
+ );
441
+ await client.query(
442
+ `ALTER TABLE analytics_events ADD COLUMN IF NOT EXISTS campaign_id text`
443
+ );
444
+ await client.query(
445
+ `ALTER TABLE analytics_events ADD COLUMN IF NOT EXISTS ad_set_id text`
446
+ );
447
+ await client.query(
448
+ `ALTER TABLE analytics_events ADD COLUMN IF NOT EXISTS ad_id text`
449
+ );
450
+ await client.query(
451
+ `ALTER TABLE analytics_events ADD COLUMN IF NOT EXISTS creative_id text`
452
+ );
453
+ await client.query(
454
+ `ALTER TABLE analytics_events ADD COLUMN IF NOT EXISTS placement text`
455
+ );
456
+ await client.query(
457
+ `ALTER TABLE analytics_events ADD COLUMN IF NOT EXISTS utm_term text`
458
+ );
459
+ await client.query(
460
+ `ALTER TABLE analytics_events ADD COLUMN IF NOT EXISTS utm_content text`
461
+ );
462
+ await client.query(
463
+ `ALTER TABLE analytics_conversions ADD COLUMN IF NOT EXISTS person_id uuid REFERENCES analytics_people(id) ON DELETE SET NULL`
464
+ );
465
+ await client.query(
466
+ `ALTER TABLE analytics_sites ADD COLUMN IF NOT EXISTS business_model text NOT NULL DEFAULT 'leadgen' CHECK (business_model IN ('leadgen','saas','ecommerce'))`
467
+ );
468
+ await client.query(`
469
+ CREATE TABLE IF NOT EXISTS analytics_ad_spend_daily (
470
+ site_id uuid NOT NULL REFERENCES analytics_sites(id) ON DELETE CASCADE,
471
+ bucket date NOT NULL,
472
+ platform text NOT NULL,
473
+ campaign text NOT NULL DEFAULT '(all)',
474
+ spend_minor bigint NOT NULL CHECK (spend_minor >= 0),
475
+ currency text NOT NULL DEFAULT 'USD',
476
+ source_ref text,
477
+ updated_at timestamptz NOT NULL DEFAULT now(),
478
+ PRIMARY KEY(site_id, bucket, platform, campaign)
479
+ )
480
+ `);
481
+ await client.query(`
482
+ CREATE TABLE IF NOT EXISTS analytics_crm_imports (
483
+ id uuid PRIMARY KEY,
484
+ site_id uuid NOT NULL REFERENCES analytics_sites(id) ON DELETE CASCADE,
485
+ requested_by_user_id bigint NOT NULL,
486
+ source_system text NOT NULL,
487
+ filename text NOT NULL,
488
+ status text NOT NULL DEFAULT 'queued' CHECK (status IN ('queued','processing','complete','partial','failed')),
489
+ accepted_count integer NOT NULL DEFAULT 0,
490
+ rejected_count integer NOT NULL DEFAULT 0,
491
+ created_at timestamptz NOT NULL DEFAULT now(),
492
+ completed_at timestamptz
493
+ )
494
+ `);
495
+ await client.query(`
496
+ CREATE TABLE IF NOT EXISTS analytics_crm_import_rows (
497
+ id uuid PRIMARY KEY,
498
+ import_id uuid NOT NULL REFERENCES analytics_crm_imports(id) ON DELETE CASCADE,
499
+ crm_person_ref text NOT NULL,
500
+ payload_ciphertext text NOT NULL,
501
+ delivery_status text NOT NULL DEFAULT 'pending' CHECK (delivery_status IN ('pending','delivered','failed','expired')),
502
+ attempts integer NOT NULL DEFAULT 0,
503
+ last_error_code text,
504
+ created_at timestamptz NOT NULL DEFAULT now(),
505
+ delivered_at timestamptz,
506
+ UNIQUE(import_id, crm_person_ref)
507
+ )
508
+ `);
509
+ await client.query(`
510
+ CREATE TABLE IF NOT EXISTS analytics_activation_destinations (
511
+ id uuid PRIMARY KEY,
512
+ site_id uuid NOT NULL REFERENCES analytics_sites(id) ON DELETE CASCADE,
513
+ platform text NOT NULL CHECK (platform IN ('meta','google','tiktok','reddit')),
514
+ name text NOT NULL,
515
+ connection_ref text,
516
+ external_dataset_id text,
517
+ status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','paused','archived')),
518
+ event_mapping jsonb NOT NULL DEFAULT '{}'::jsonb,
519
+ created_by_user_id bigint NOT NULL,
520
+ created_at timestamptz NOT NULL DEFAULT now(),
521
+ updated_at timestamptz NOT NULL DEFAULT now()
522
+ )
523
+ `);
524
+ await client.query(`
525
+ CREATE TABLE IF NOT EXISTS analytics_activation_jobs (
526
+ id uuid PRIMARY KEY,
527
+ destination_id uuid NOT NULL REFERENCES analytics_activation_destinations(id) ON DELETE CASCADE,
528
+ conversion_id uuid NOT NULL REFERENCES analytics_conversions(id) ON DELETE CASCADE,
529
+ person_id uuid REFERENCES analytics_people(id) ON DELETE SET NULL,
530
+ payload_ciphertext text NOT NULL,
531
+ status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','delivered','failed','expired')),
532
+ attempts integer NOT NULL DEFAULT 0,
533
+ last_error_code text,
534
+ next_attempt_at timestamptz NOT NULL DEFAULT now(),
535
+ created_at timestamptz NOT NULL DEFAULT now(),
536
+ delivered_at timestamptz,
537
+ UNIQUE(destination_id, conversion_id)
538
+ )
539
+ `);
540
+ await client.query(`
541
+ CREATE TABLE IF NOT EXISTS analytics_thorbit_entitlements (
542
+ user_id bigint PRIMARY KEY,
543
+ thorbit_org_id text,
544
+ connection_source text NOT NULL DEFAULT 'trial' CHECK (connection_source IN ('trial','api_key','thorbit_session')),
545
+ eligible boolean NOT NULL DEFAULT false,
546
+ reason text NOT NULL,
547
+ plan_id text,
548
+ subscription_status text,
549
+ monthly_recurring_revenue_cents integer NOT NULL DEFAULT 0,
550
+ minimum_monthly_recurring_revenue_cents integer NOT NULL DEFAULT 5000,
551
+ verified_at timestamptz NOT NULL,
552
+ refresh_after timestamptz NOT NULL,
553
+ access_expires_at timestamptz NOT NULL,
554
+ trial_started_at timestamptz NOT NULL DEFAULT now(),
555
+ trial_ends_at timestamptz NOT NULL DEFAULT (now() + interval '30 days'),
556
+ last_refresh_error text,
557
+ updated_at timestamptz NOT NULL DEFAULT now()
558
+ )
559
+ `);
560
+ await client.query(
561
+ `ALTER TABLE analytics_thorbit_entitlements ALTER COLUMN thorbit_org_id DROP NOT NULL`
562
+ );
563
+ await client.query(
564
+ `ALTER TABLE analytics_thorbit_entitlements ADD COLUMN IF NOT EXISTS trial_started_at timestamptz NOT NULL DEFAULT now()`
565
+ );
566
+ await client.query(
567
+ `ALTER TABLE analytics_thorbit_entitlements ADD COLUMN IF NOT EXISTS trial_ends_at timestamptz NOT NULL DEFAULT (now() + interval '30 days')`
568
+ );
569
+ await client.query(
570
+ `ALTER TABLE analytics_thorbit_entitlements DROP CONSTRAINT IF EXISTS analytics_thorbit_entitlements_connection_source_check`
571
+ );
572
+ await client.query(
573
+ `ALTER TABLE analytics_thorbit_entitlements ADD CONSTRAINT analytics_thorbit_entitlements_connection_source_check CHECK (connection_source IN ('trial','api_key','thorbit_session'))`
574
+ );
575
+ await client.query(`
576
+ CREATE TABLE IF NOT EXISTS analytics_thorbit_connect_states (
577
+ state_hash text PRIMARY KEY,
578
+ user_id bigint NOT NULL,
579
+ expires_at timestamptz NOT NULL,
580
+ consumed_at timestamptz,
581
+ created_at timestamptz NOT NULL DEFAULT now()
582
+ )
583
+ `);
584
+ await client.query(
585
+ `ALTER TABLE analytics_exports ADD COLUMN IF NOT EXISTS idempotency_key text`
586
+ );
587
+ await client.query(
588
+ `ALTER TABLE analytics_events ADD COLUMN IF NOT EXISTS country_code text`
589
+ );
590
+ await client.query(
591
+ `ALTER TABLE analytics_events ADD COLUMN IF NOT EXISTS region_code text`
592
+ );
593
+ await client.query(`CREATE UNIQUE INDEX IF NOT EXISTS analytics_exports_owner_idempotency
594
+ ON analytics_exports(requested_by_user_id, idempotency_key) WHERE idempotency_key IS NOT NULL`);
595
+ await client.query(
596
+ "CREATE INDEX IF NOT EXISTS analytics_events_site_time ON analytics_events(site_id, occurred_at DESC)"
597
+ );
598
+ await client.query(
599
+ "CREATE INDEX IF NOT EXISTS analytics_events_pixel_time ON analytics_events(pixel_id, occurred_at DESC)"
600
+ );
601
+ await client.query(
602
+ "CREATE INDEX IF NOT EXISTS analytics_events_site_hostname_time ON analytics_events(site_id, hostname, occurred_at DESC)"
603
+ );
604
+ await client.query(
605
+ "CREATE INDEX IF NOT EXISTS analytics_events_acquisition ON analytics_events(site_id, source, medium, campaign, occurred_at DESC)"
606
+ );
607
+ await client.query(
608
+ "CREATE INDEX IF NOT EXISTS analytics_conversions_site_time ON analytics_conversions(site_id, occurred_at DESC)"
609
+ );
610
+ await client.query(`
611
+ INSERT INTO analytics_schema_migrations(version)
612
+ VALUES ('2026-08-05.1'), ('2026-08-05.2'), ('2026-08-05.3'), ('2026-08-05.4'), ('2026-08-05.5'), ('2026-08-05.6'), ('2026-08-05.7'), ('2026-08-05.8'), ('2026-08-05.9'), ('2026-08-05.10'), ('2026-08-05.11'), ('2026-08-05.12'), ('2026-08-05.13'), ('2026-08-05.14')
613
+ ON CONFLICT (version) DO NOTHING
614
+ `);
615
+ await client.query("COMMIT");
616
+ } catch (error) {
617
+ await client.query("ROLLBACK");
618
+ throw error;
619
+ } finally {
620
+ client.release();
621
+ }
622
+ }
623
+ function normalizeSlug(value) {
624
+ const slug = value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80);
625
+ return slug || `site-${randomBytes(4).toString("hex")}`;
626
+ }
627
+ function normalizeObservedHostname(origin) {
628
+ if (!origin || origin === "null") return null;
629
+ try {
630
+ const url = new URL(origin);
631
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
632
+ return url.hostname.toLowerCase().replace(/\.$/, "");
633
+ } catch {
634
+ return null;
635
+ }
636
+ }
637
+ function publicPixelId() {
638
+ return `px_${randomBytes(18).toString("base64url")}`;
639
+ }
640
+ function mapRows(rows) {
641
+ return rows;
642
+ }
643
+ async function accessFor(client, siteId, userId) {
644
+ const result = await client.query(
645
+ `SELECT role FROM analytics_site_grants WHERE site_id = $1 AND user_id = $2`,
646
+ [siteId, userId]
647
+ );
648
+ const role = result.rows[0]?.role;
649
+ if (!role)
650
+ throw new AnalyticsRepositoryError(
651
+ "analytics_site_not_found",
652
+ "Site not found.",
653
+ 404
654
+ );
655
+ return role;
656
+ }
657
+ async function requireEditor(client, siteId, userId) {
658
+ const role = await accessFor(client, siteId, userId);
659
+ if (role === "viewer")
660
+ throw new AnalyticsRepositoryError(
661
+ "analytics_forbidden",
662
+ "Editor access is required.",
663
+ 403
664
+ );
665
+ return role;
666
+ }
667
+ async function createAnalyticsSite(input) {
668
+ const db = getAnalyticsPool();
669
+ const client = await db.connect();
670
+ const id = randomUUID();
671
+ const baseSlug = normalizeSlug(input.slug || input.name);
672
+ try {
673
+ await client.query("BEGIN");
674
+ let slug = baseSlug;
675
+ for (let attempt = 0; attempt < 3; attempt += 1) {
676
+ try {
677
+ await client.query(
678
+ `INSERT INTO analytics_sites(id, slug, name, owner_user_id, timezone)
679
+ VALUES ($1, $2, $3, $4, $5)`,
680
+ [id, slug, input.name.trim(), input.userId, input.timezone || "UTC"]
681
+ );
682
+ break;
683
+ } catch (error) {
684
+ const code = error.code;
685
+ if (code !== "23505" || attempt === 2) throw error;
686
+ slug = `${baseSlug}-${randomBytes(2).toString("hex")}`;
687
+ }
688
+ }
689
+ await client.query(
690
+ `INSERT INTO analytics_site_grants(site_id, user_id, role) VALUES ($1, $2, 'owner')`,
691
+ [id, input.userId]
692
+ );
693
+ await client.query("COMMIT");
694
+ return {
695
+ id,
696
+ slug,
697
+ name: input.name.trim(),
698
+ timezone: input.timezone || "UTC",
699
+ status: "active",
700
+ role: "owner",
701
+ pixel_count: 0,
702
+ last_event_at: null,
703
+ business_model: "leadgen"
704
+ };
705
+ } catch (error) {
706
+ await client.query("ROLLBACK");
707
+ throw error;
708
+ } finally {
709
+ client.release();
710
+ }
711
+ }
712
+ async function listAnalyticsSites(userId) {
713
+ const result = await getAnalyticsPool().query(
714
+ `SELECT s.id, s.slug, s.name, s.timezone, s.status, s.business_model, g.role,
715
+ count(DISTINCT p.id)::int AS pixel_count,
716
+ max(e.occurred_at)::text AS last_event_at
717
+ FROM analytics_sites s
718
+ JOIN analytics_site_grants g ON g.site_id = s.id AND g.user_id = $1
719
+ LEFT JOIN analytics_pixels p ON p.site_id = s.id
720
+ LEFT JOIN analytics_events e ON e.site_id = s.id
721
+ GROUP BY s.id, g.role
722
+ ORDER BY s.created_at ASC`,
723
+ [userId]
724
+ );
725
+ return mapRows(result.rows);
726
+ }
727
+ async function updateAnalyticsBusinessModel(input) {
728
+ const db = getAnalyticsPool();
729
+ await requireEditor(db, input.siteId, input.userId);
730
+ await db.query(
731
+ "UPDATE analytics_sites SET business_model = $1, updated_at = now() WHERE id = $2",
732
+ [input.businessModel, input.siteId]
733
+ );
734
+ }
735
+ async function upsertAnalyticsAdSpend(input) {
736
+ const db = getAnalyticsPool();
737
+ await requireEditor(db, input.siteId, input.userId);
738
+ for (const row of input.rows) {
739
+ await db.query(
740
+ `INSERT INTO analytics_ad_spend_daily(site_id, bucket, platform, campaign, spend_minor, currency, source_ref)
741
+ VALUES ($1,$2::date,$3,$4,$5,$6,$7)
742
+ ON CONFLICT(site_id,bucket,platform,campaign) DO UPDATE SET spend_minor=EXCLUDED.spend_minor,currency=EXCLUDED.currency,source_ref=EXCLUDED.source_ref,updated_at=now()`,
743
+ [
744
+ input.siteId,
745
+ row.date,
746
+ row.platform.slice(0, 80),
747
+ row.campaign?.slice(0, 240) || "(all)",
748
+ row.spendMinor,
749
+ (row.currency || "USD").toUpperCase(),
750
+ row.sourceRef?.slice(0, 500) || null
751
+ ]
752
+ );
753
+ }
754
+ return { accepted: input.rows.length };
755
+ }
756
+ async function analyticsBusinessMetrics(siteId, userId, start, end) {
757
+ const db = getAnalyticsPool();
758
+ await accessFor(db, siteId, userId);
759
+ const site = await db.query("SELECT business_model FROM analytics_sites WHERE id=$1", [siteId]);
760
+ const result = await db.query(
761
+ `SELECT count(*)::int AS conversions, count(DISTINCT person_id)::int AS customers,
762
+ count(*) FILTER (WHERE conversion_kind IN ('form_submitted','lead','qualified_lead'))::int AS leads,
763
+ count(*) FILTER (WHERE conversion_kind IN ('purchase','order_completed'))::int AS purchases,
764
+ count(*) FILTER (WHERE conversion_kind IN ('trial_started','demo_started'))::int AS trials,
765
+ count(*) FILTER (WHERE conversion_kind IN ('subscription_started','subscription_renewed'))::int AS subscriptions,
766
+ count(*) FILTER (WHERE conversion_kind IN ('subscription_cancelled','churned'))::int AS churns,
767
+ COALESCE(sum(value_minor),0)::bigint AS revenue_minor,
768
+ (SELECT count(*)::int FROM (SELECT person_id FROM analytics_conversions WHERE site_id=$1 AND person_id IS NOT NULL AND conversion_kind IN ('purchase','order_completed') AND occurred_at >= $2 AND occurred_at < $3 GROUP BY person_id HAVING count(*) > 1) r) AS repeat_customers
769
+ FROM analytics_conversions WHERE site_id=$1 AND occurred_at >= $2 AND occurred_at < $3`,
770
+ [siteId, start, end]
771
+ );
772
+ const spend = await db.query(
773
+ "SELECT COALESCE(sum(spend_minor),0)::bigint AS spend_minor FROM analytics_ad_spend_daily WHERE site_id=$1 AND bucket >= $2::date AND bucket < $3::date",
774
+ [siteId, start, end]
775
+ );
776
+ const row = result.rows[0];
777
+ const spendMinor = Number(spend.rows[0]?.spend_minor || 0);
778
+ const revenueMinor = Number(row.revenue_minor || 0);
779
+ return {
780
+ businessModel: site.rows[0]?.business_model || "leadgen",
781
+ period: { start, end },
782
+ metrics: {
783
+ ...row,
784
+ revenue_minor: revenueMinor,
785
+ spend_minor: spendMinor,
786
+ roas: spendMinor > 0 ? revenueMinor / spendMinor : null,
787
+ cost_per_lead: spendMinor > 0 && row.leads > 0 ? spendMinor / row.leads : null,
788
+ average_order_value: row.purchases > 0 ? revenueMinor / row.purchases : null,
789
+ repeat_customer_rate: row.customers > 0 ? row.repeat_customers / row.customers : null,
790
+ churn_rate: row.subscriptions > 0 ? row.churns / row.subscriptions : null
791
+ },
792
+ missingInputs: spendMinor > 0 ? [] : ["ad_spend"]
793
+ };
794
+ }
795
+ async function analyticsForecast(siteId, userId, horizonMonths = 12) {
796
+ const db = getAnalyticsPool();
797
+ await accessFor(db, siteId, userId);
798
+ const rows = await db.query(
799
+ `WITH months AS (SELECT generate_series(date_trunc('month', now()) - interval '11 months', date_trunc('month', now()), interval '1 month') AS month)
800
+ SELECT m.month::date::text AS month,
801
+ COALESCE((SELECT sum(value_minor) FROM analytics_conversions c WHERE c.site_id=$1 AND c.occurred_at >= m.month AND c.occurred_at < m.month+interval '1 month'),0)::bigint AS revenue_minor,
802
+ COALESCE((SELECT sum(spend_minor) FROM analytics_ad_spend_daily s WHERE s.site_id=$1 AND s.bucket >= m.month::date AND s.bucket < (m.month+interval '1 month')::date),0)::bigint AS spend_minor
803
+ FROM months m ORDER BY m.month`,
804
+ [siteId]
805
+ );
806
+ const history = rows.rows.map((row) => ({
807
+ month: row.month,
808
+ revenueMinor: Number(row.revenue_minor),
809
+ spendMinor: Number(row.spend_minor)
810
+ }));
811
+ const nonEmpty = history.filter(
812
+ (row) => row.revenueMinor > 0 || row.spendMinor > 0
813
+ );
814
+ if (nonEmpty.length < 3)
815
+ return {
816
+ status: "insufficient_data",
817
+ history,
818
+ forecast: [],
819
+ horizonMonths,
820
+ missingInputs: ["at_least_3_months_of_revenue_or_spend"]
821
+ };
822
+ const values = history.map((row) => row.revenueMinor);
823
+ const n = values.length;
824
+ const meanX = (n - 1) / 2;
825
+ const meanY = values.reduce((a, b) => a + b, 0) / n;
826
+ const slope = values.reduce(
827
+ (sum, value, index) => sum + (index - meanX) * (value - meanY),
828
+ 0
829
+ ) / values.reduce((sum, _value, index) => sum + (index - meanX) ** 2, 0);
830
+ const intercept = meanY - slope * meanX;
831
+ const residual = Math.sqrt(
832
+ values.reduce(
833
+ (sum, value, index) => sum + (value - (intercept + slope * index)) ** 2,
834
+ 0
835
+ ) / n
836
+ );
837
+ const spendMonths = history.filter((row) => row.spendMinor > 0);
838
+ const avgSpend = spendMonths.length ? spendMonths.reduce((sum, row) => sum + row.spendMinor, 0) / spendMonths.length : 0;
839
+ const lastMonth = /* @__PURE__ */ new Date(`${history.at(-1)?.month}T00:00:00Z`);
840
+ const forecast = Array.from(
841
+ { length: Math.min(Math.max(horizonMonths, 1), 36) },
842
+ (_, offset) => {
843
+ const date = new Date(
844
+ Date.UTC(
845
+ lastMonth.getUTCFullYear(),
846
+ lastMonth.getUTCMonth() + offset + 1,
847
+ 1
848
+ )
849
+ );
850
+ const base = Math.max(0, intercept + slope * (n + offset));
851
+ return {
852
+ month: date.toISOString().slice(0, 10),
853
+ lowerRevenueMinor: Math.max(0, Math.round(base - residual)),
854
+ baseRevenueMinor: Math.round(base),
855
+ upperRevenueMinor: Math.round(base + residual),
856
+ projectedSpendMinor: Math.round(avgSpend),
857
+ baseRoas: avgSpend > 0 ? base / avgSpend : null
858
+ };
859
+ }
860
+ );
861
+ return {
862
+ status: "modeled",
863
+ method: "monthly_linear_trend_v1",
864
+ history,
865
+ forecast,
866
+ horizonMonths: forecast.length,
867
+ missingInputs: avgSpend > 0 ? [] : ["ad_spend"],
868
+ caveats: [
869
+ "Scenario range reflects historical residual variation, not a guarantee."
870
+ ]
871
+ };
872
+ }
873
+ async function deleteAnalyticsSite(input) {
874
+ const db = getAnalyticsPool();
875
+ const role = await accessFor(db, input.siteId, input.userId);
876
+ if (role !== "owner")
877
+ throw new AnalyticsRepositoryError(
878
+ "analytics_forbidden",
879
+ "Owner access is required.",
880
+ 403
881
+ );
882
+ const result = await db.query(
883
+ `DELETE FROM analytics_sites WHERE id = $1 AND owner_user_id = $2`,
884
+ [input.siteId, input.userId]
885
+ );
886
+ if (!result.rowCount)
887
+ throw new AnalyticsRepositoryError(
888
+ "analytics_site_not_found",
889
+ "Site not found.",
890
+ 404
891
+ );
892
+ }
893
+ async function createAnalyticsPixel(input) {
894
+ const db = getAnalyticsPool();
895
+ await requireEditor(db, input.siteId, input.userId);
896
+ const row = await db.query(
897
+ `INSERT INTO analytics_pixels(id, site_id, public_id, name, environment)
898
+ VALUES ($1, $2, $3, $4, $5)
899
+ RETURNING id, site_id, public_id, name, environment, status, created_at::text, NULL::text AS last_event_at`,
900
+ [
901
+ randomUUID(),
902
+ input.siteId,
903
+ publicPixelId(),
904
+ input.name.trim(),
905
+ input.environment || "production"
906
+ ]
907
+ );
908
+ return { ...row.rows[0], domains: [] };
909
+ }
910
+ async function listAnalyticsPixels(siteId, userId) {
911
+ const db = getAnalyticsPool();
912
+ await accessFor(db, siteId, userId);
913
+ const [pixels, domains] = await Promise.all([
914
+ db.query(
915
+ `SELECT p.id, p.site_id, p.public_id, p.name, p.environment, p.status,
916
+ p.created_at::text, max(e.occurred_at)::text AS last_event_at
917
+ FROM analytics_pixels p
918
+ LEFT JOIN analytics_events e ON e.pixel_id = p.id
919
+ WHERE p.site_id = $1
920
+ GROUP BY p.id
921
+ ORDER BY p.created_at ASC`,
922
+ [siteId]
923
+ ),
924
+ db.query(
925
+ `SELECT pixel_id, hostname, state, first_seen_at::text, last_seen_at::text,
926
+ accepted_count::int, rejected_count::int
927
+ FROM analytics_pixel_domains
928
+ WHERE pixel_id IN (SELECT id FROM analytics_pixels WHERE site_id = $1)
929
+ ORDER BY last_seen_at DESC`,
930
+ [siteId]
931
+ )
932
+ ]);
933
+ return pixels.rows.map((pixel) => ({
934
+ ...pixel,
935
+ domains: domains.rows.filter((domain) => domain.pixel_id === pixel.id).map(({ pixel_id: _pixelId, ...domain }) => domain)
936
+ }));
937
+ }
938
+ async function updateAnalyticsPixel(input) {
939
+ const db = getAnalyticsPool();
940
+ await requireEditor(db, input.siteId, input.userId);
941
+ const result = await db.query(
942
+ `UPDATE analytics_pixels
943
+ SET name = COALESCE($3, name),
944
+ environment = COALESCE($4, environment),
945
+ status = COALESCE($5, status),
946
+ disabled_at = CASE WHEN $5 = 'disabled' THEN now() WHEN $5 = 'active' THEN NULL ELSE disabled_at END,
947
+ updated_at = now()
948
+ WHERE id = $1 AND site_id = $2`,
949
+ [
950
+ input.pixelId,
951
+ input.siteId,
952
+ input.name ?? null,
953
+ input.environment ?? null,
954
+ input.status ?? null
955
+ ]
956
+ );
957
+ if (!result.rowCount)
958
+ throw new AnalyticsRepositoryError(
959
+ "analytics_pixel_not_found",
960
+ "Pixel not found.",
961
+ 404
962
+ );
963
+ }
964
+ async function setAnalyticsPixelDomainState(input) {
965
+ const db = getAnalyticsPool();
966
+ await requireEditor(db, input.siteId, input.userId);
967
+ const hostname = normalizeObservedHostname(`https://${input.hostname}`);
968
+ if (!hostname)
969
+ throw new AnalyticsRepositoryError(
970
+ "invalid_hostname",
971
+ "Enter a valid hostname."
972
+ );
973
+ const result = await db.query(
974
+ `INSERT INTO analytics_pixel_domains(id, pixel_id, hostname, state)
975
+ SELECT $1, p.id, $4, $5 FROM analytics_pixels p WHERE p.id = $2 AND p.site_id = $3
976
+ ON CONFLICT(pixel_id, hostname) DO UPDATE SET state = EXCLUDED.state, updated_at = now()
977
+ RETURNING id`,
978
+ [randomUUID(), input.pixelId, input.siteId, hostname, input.state]
979
+ );
980
+ if (!result.rowCount)
981
+ throw new AnalyticsRepositoryError(
982
+ "analytics_pixel_not_found",
983
+ "Pixel not found.",
984
+ 404
985
+ );
986
+ }
987
+ var blockedPropertyName = /(email|phone|password|token|secret|authorization|cookie|name|address|form|body)/i;
988
+ function normalizeAnalyticsPath(value) {
989
+ if (!value) return "/";
990
+ try {
991
+ const parsed = new URL(value, "https://analytics.invalid");
992
+ return parsed.pathname.slice(0, 2e3) || "/";
993
+ } catch {
994
+ return "/";
995
+ }
996
+ }
997
+ function normalizeAnalyticsUrl(value) {
998
+ if (!value) return null;
999
+ try {
1000
+ const parsed = new URL(value);
1001
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
1002
+ return null;
1003
+ parsed.search = "";
1004
+ parsed.hash = "";
1005
+ return parsed.toString().slice(0, 3e3);
1006
+ } catch {
1007
+ return null;
1008
+ }
1009
+ }
1010
+ function sanitizeAnalyticsProperties(value) {
1011
+ const output = {};
1012
+ for (const [key, property] of Object.entries(value ?? {}).slice(0, 24)) {
1013
+ if (!/^[a-z][a-z0-9_]{0,63}$/i.test(key) || blockedPropertyName.test(key))
1014
+ continue;
1015
+ if (property === null || typeof property === "boolean" || typeof property === "number")
1016
+ output[key] = property;
1017
+ else if (typeof property === "string") output[key] = property.slice(0, 300);
1018
+ }
1019
+ return output;
1020
+ }
1021
+ async function ingestAnalyticsEvents(input) {
1022
+ const db = getAnalyticsPool();
1023
+ const requestId = `air_${randomUUID()}`;
1024
+ const hostname = normalizeObservedHostname(input.origin);
1025
+ const pixelResult = await db.query(
1026
+ `SELECT p.id, p.site_id, p.status,
1027
+ COALESCE(
1028
+ (e.eligible AND e.access_expires_at > now()) OR e.trial_ends_at > now(),
1029
+ false
1030
+ ) AS entitled
1031
+ FROM analytics_pixels p
1032
+ JOIN analytics_sites s ON s.id = p.site_id
1033
+ LEFT JOIN analytics_thorbit_entitlements e ON e.user_id = s.owner_user_id
1034
+ WHERE p.public_id = $1`,
1035
+ [input.publicPixelId]
1036
+ );
1037
+ const pixel = pixelResult.rows[0];
1038
+ if (!pixel || pixel.status !== "active") {
1039
+ const reasons = [pixel ? "pixel_disabled" : "pixel_unknown"];
1040
+ await db.query(
1041
+ `INSERT INTO analytics_ingestion_receipts(id, request_id, site_id, pixel_id, hostname, rejected_count, reason_codes)
1042
+ VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`,
1043
+ [
1044
+ randomUUID(),
1045
+ requestId,
1046
+ pixel?.site_id ?? null,
1047
+ pixel?.id ?? null,
1048
+ hostname,
1049
+ input.events.length,
1050
+ JSON.stringify(reasons)
1051
+ ]
1052
+ );
1053
+ return {
1054
+ requestId,
1055
+ accepted: 0,
1056
+ rejected: input.events.length,
1057
+ reasonCodes: reasons,
1058
+ hostname,
1059
+ siteId: pixel?.site_id ?? null,
1060
+ pixelId: pixel?.id ?? null
1061
+ };
1062
+ }
1063
+ if (!pixel.entitled) {
1064
+ const reasons = ["thorbit_entitlement_required"];
1065
+ await db.query(
1066
+ `INSERT INTO analytics_ingestion_receipts(id, request_id, site_id, pixel_id, hostname, rejected_count, reason_codes)
1067
+ VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`,
1068
+ [
1069
+ randomUUID(),
1070
+ requestId,
1071
+ pixel.site_id,
1072
+ pixel.id,
1073
+ hostname,
1074
+ input.events.length,
1075
+ JSON.stringify(reasons)
1076
+ ]
1077
+ );
1078
+ return {
1079
+ requestId,
1080
+ accepted: 0,
1081
+ rejected: input.events.length,
1082
+ reasonCodes: reasons,
1083
+ hostname,
1084
+ siteId: pixel.site_id,
1085
+ pixelId: pixel.id
1086
+ };
1087
+ }
1088
+ if (!hostname) {
1089
+ const reasons = ["origin_missing_or_invalid"];
1090
+ await db.query(
1091
+ `INSERT INTO analytics_ingestion_receipts(id, request_id, site_id, pixel_id, rejected_count, reason_codes)
1092
+ VALUES ($1, $2, $3, $4, $5, $6::jsonb)`,
1093
+ [
1094
+ randomUUID(),
1095
+ requestId,
1096
+ pixel.site_id,
1097
+ pixel.id,
1098
+ input.events.length,
1099
+ JSON.stringify(reasons)
1100
+ ]
1101
+ );
1102
+ return {
1103
+ requestId,
1104
+ accepted: 0,
1105
+ rejected: input.events.length,
1106
+ reasonCodes: reasons,
1107
+ hostname,
1108
+ siteId: pixel.site_id,
1109
+ pixelId: pixel.id
1110
+ };
1111
+ }
1112
+ const domainResult = await db.query(
1113
+ `INSERT INTO analytics_pixel_domains(id, pixel_id, hostname, state, rejected_count)
1114
+ VALUES ($1, $2, $3, 'observed_unverified', 0)
1115
+ ON CONFLICT(pixel_id, hostname) DO UPDATE
1116
+ SET last_seen_at = now(), updated_at = now()
1117
+ RETURNING state`,
1118
+ [randomUUID(), pixel.id, hostname]
1119
+ );
1120
+ if (domainResult.rows[0]?.state !== "approved") {
1121
+ await db.query(
1122
+ `UPDATE analytics_pixel_domains
1123
+ SET rejected_count = rejected_count + $3, last_seen_at = now(), updated_at = now()
1124
+ WHERE pixel_id = $1 AND hostname = $2`,
1125
+ [pixel.id, hostname, input.events.length]
1126
+ );
1127
+ const reasons = ["domain_unapproved"];
1128
+ await db.query(
1129
+ `INSERT INTO analytics_ingestion_receipts(id, request_id, site_id, pixel_id, hostname, rejected_count, reason_codes)
1130
+ VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`,
1131
+ [
1132
+ randomUUID(),
1133
+ requestId,
1134
+ pixel.site_id,
1135
+ pixel.id,
1136
+ hostname,
1137
+ input.events.length,
1138
+ JSON.stringify(reasons)
1139
+ ]
1140
+ );
1141
+ return {
1142
+ requestId,
1143
+ accepted: 0,
1144
+ rejected: input.events.length,
1145
+ reasonCodes: reasons,
1146
+ hostname,
1147
+ siteId: pixel.site_id,
1148
+ pixelId: pixel.id
1149
+ };
1150
+ }
1151
+ const client = await db.connect();
1152
+ let accepted = 0;
1153
+ let rejected = 0;
1154
+ const reasonCodes = /* @__PURE__ */ new Set();
1155
+ try {
1156
+ await client.query("BEGIN");
1157
+ for (const event of input.events) {
1158
+ try {
1159
+ const attribution = resolveAnalyticsAttribution(event);
1160
+ const resolvedPerson = event.visitorId || event.sessionId ? await client.query(
1161
+ `SELECT e.person_id FROM analytics_identity_nodes n JOIN analytics_identity_edges e ON e.identity_node_id = n.id
1162
+ WHERE n.site_id = $1 AND ((n.kind = 'visitor_id' AND n.value_hmac = $2) OR (n.kind = 'session_id' AND n.value_hmac = $3))
1163
+ ORDER BY e.confidence DESC, e.last_seen_at DESC LIMIT 1`,
1164
+ [
1165
+ pixel.site_id,
1166
+ event.visitorId ? identityHmac("visitor_id", event.visitorId) : "",
1167
+ event.sessionId ? identityHmac("session_id", event.sessionId) : ""
1168
+ ]
1169
+ ) : { rows: [] };
1170
+ const inserted = await client.query(
1171
+ `INSERT INTO analytics_events(
1172
+ id, event_id, site_id, pixel_id, hostname, visitor_id, session_id, event_name,
1173
+ occurred_at, path, canonical_url, title, referrer, source, medium, campaign,
1174
+ device_class, properties, country_code, region_code, click_ids, person_id,
1175
+ channel_family, platform, campaign_id, ad_set_id, ad_id, creative_id, placement, utm_term, utm_content
1176
+ ) VALUES (
1177
+ $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18::jsonb, $19, $20, $21::jsonb, $22,
1178
+ $23, $24, $25, $26, $27, $28, $29, $30, $31
1179
+ ) ON CONFLICT(site_id, event_id) DO NOTHING`,
1180
+ [
1181
+ randomUUID(),
1182
+ event.eventId,
1183
+ pixel.site_id,
1184
+ pixel.id,
1185
+ hostname,
1186
+ event.visitorId ?? null,
1187
+ event.sessionId ?? null,
1188
+ event.eventName,
1189
+ event.occurredAt,
1190
+ normalizeAnalyticsPath(event.path || event.canonicalUrl),
1191
+ normalizeAnalyticsUrl(event.canonicalUrl),
1192
+ event.title?.slice(0, 500) ?? null,
1193
+ normalizeAnalyticsUrl(event.referrer),
1194
+ event.source?.slice(0, 180) ?? null,
1195
+ event.medium?.slice(0, 180) ?? null,
1196
+ event.campaign?.slice(0, 240) ?? null,
1197
+ event.deviceClass ?? null,
1198
+ JSON.stringify(sanitizeAnalyticsProperties(event.properties)),
1199
+ normalizeGeographyCode(input.geography?.countryCode, 2),
1200
+ normalizeGeographyCode(input.geography?.regionCode, 8),
1201
+ JSON.stringify(sanitizeClickIds(event.clickIds)),
1202
+ resolvedPerson.rows[0]?.person_id ?? null,
1203
+ attribution.channelFamily,
1204
+ attribution.platform,
1205
+ attribution.campaignId,
1206
+ attribution.adSetId,
1207
+ attribution.adId,
1208
+ attribution.creativeId,
1209
+ attribution.placement,
1210
+ attribution.term,
1211
+ attribution.content
1212
+ ]
1213
+ );
1214
+ if (inserted.rowCount) accepted += 1;
1215
+ else {
1216
+ rejected += 1;
1217
+ reasonCodes.add("duplicate_event");
1218
+ }
1219
+ } catch {
1220
+ rejected += 1;
1221
+ reasonCodes.add("event_insert_failed");
1222
+ }
1223
+ }
1224
+ await client.query(
1225
+ `UPDATE analytics_pixel_domains
1226
+ SET accepted_count = accepted_count + $3,
1227
+ rejected_count = rejected_count + $4,
1228
+ last_seen_at = now(), updated_at = now()
1229
+ WHERE pixel_id = $1 AND hostname = $2`,
1230
+ [pixel.id, hostname, accepted, rejected]
1231
+ );
1232
+ await client.query(
1233
+ `INSERT INTO analytics_ingestion_receipts(
1234
+ id, request_id, site_id, pixel_id, hostname, accepted_count, rejected_count, reason_codes
1235
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)`,
1236
+ [
1237
+ randomUUID(),
1238
+ requestId,
1239
+ pixel.site_id,
1240
+ pixel.id,
1241
+ hostname,
1242
+ accepted,
1243
+ rejected,
1244
+ JSON.stringify([...reasonCodes])
1245
+ ]
1246
+ );
1247
+ await client.query("COMMIT");
1248
+ } catch (error) {
1249
+ await client.query("ROLLBACK");
1250
+ throw error;
1251
+ } finally {
1252
+ client.release();
1253
+ }
1254
+ return {
1255
+ requestId,
1256
+ accepted,
1257
+ rejected,
1258
+ reasonCodes: [...reasonCodes],
1259
+ hostname,
1260
+ siteId: pixel.site_id,
1261
+ pixelId: pixel.id
1262
+ };
1263
+ }
1264
+ function normalizeGeographyCode(value, max) {
1265
+ const normalized = value?.trim().toUpperCase();
1266
+ return normalized && /^[A-Z0-9-]+$/.test(normalized) ? normalized.slice(0, max) : null;
1267
+ }
1268
+ function pageFingerprint(scope, siteId, filters) {
1269
+ return createHash("sha256").update(JSON.stringify({ scope, siteId, filters })).digest("base64url").slice(0, 18);
1270
+ }
1271
+ function decodePageOffset(cursor, fingerprint) {
1272
+ if (!cursor) return 0;
1273
+ try {
1274
+ const parsed = JSON.parse(
1275
+ Buffer.from(cursor, "base64url").toString("utf8")
1276
+ );
1277
+ if (parsed.v !== 1 || parsed.f !== fingerprint || !Number.isInteger(parsed.o) || Number(parsed.o) < 0)
1278
+ throw new Error("invalid");
1279
+ return Number(parsed.o);
1280
+ } catch {
1281
+ throw new AnalyticsRepositoryError(
1282
+ "invalid_cursor",
1283
+ "The pagination cursor is invalid for these filters."
1284
+ );
1285
+ }
1286
+ }
1287
+ function paginateRows(rows, input, fingerprint) {
1288
+ if (!input)
1289
+ return { items: rows, pageInfo: { hasNextPage: false, nextCursor: null } };
1290
+ const limit = Math.min(100, Math.max(1, Math.trunc(input?.limit ?? 25)));
1291
+ const offset = decodePageOffset(input?.cursor, fingerprint);
1292
+ const items = rows.slice(offset, offset + limit);
1293
+ const hasNextPage = offset + items.length < rows.length;
1294
+ return {
1295
+ items,
1296
+ pageInfo: {
1297
+ hasNextPage,
1298
+ nextCursor: hasNextPage ? Buffer.from(
1299
+ JSON.stringify({ v: 1, f: fingerprint, o: offset + items.length })
1300
+ ).toString("base64url") : null
1301
+ }
1302
+ };
1303
+ }
1304
+ async function createAnalyticsConversion(input) {
1305
+ const db = getAnalyticsPool();
1306
+ await requireEditor(db, input.siteId, input.userId);
1307
+ if (input.pixelId) {
1308
+ const pixel = await db.query(
1309
+ `SELECT 1 FROM analytics_pixels WHERE id = $1 AND site_id = $2`,
1310
+ [input.pixelId, input.siteId]
1311
+ );
1312
+ if (!pixel.rowCount)
1313
+ throw new AnalyticsRepositoryError(
1314
+ "analytics_pixel_not_found",
1315
+ "Pixel not found.",
1316
+ 404
1317
+ );
1318
+ }
1319
+ const id = randomUUID();
1320
+ const resolvedPerson = input.sessionId ? await db.query(
1321
+ `SELECT e.person_id FROM analytics_identity_nodes n JOIN analytics_identity_edges e ON e.identity_node_id = n.id
1322
+ WHERE n.site_id = $1 AND n.kind = 'session_id' AND n.value_hmac = $2 ORDER BY e.confidence DESC LIMIT 1`,
1323
+ [input.siteId, identityHmac("session_id", input.sessionId)]
1324
+ ) : { rows: [] };
1325
+ const result = await db.query(
1326
+ `INSERT INTO analytics_conversions(
1327
+ id, source_event_id, site_id, pixel_id, session_id, conversion_kind, value_minor, currency, occurred_at, person_id
1328
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
1329
+ ON CONFLICT(site_id, source_event_id) DO NOTHING
1330
+ RETURNING id`,
1331
+ [
1332
+ id,
1333
+ input.sourceEventId,
1334
+ input.siteId,
1335
+ input.pixelId ?? null,
1336
+ input.sessionId ?? null,
1337
+ input.conversionKind,
1338
+ input.valueMinor ?? 0,
1339
+ (input.currency || "USD").toUpperCase(),
1340
+ input.occurredAt,
1341
+ resolvedPerson.rows[0]?.person_id ?? null
1342
+ ]
1343
+ );
1344
+ return { id: result.rows[0]?.id ?? id, created: Boolean(result.rowCount) };
1345
+ }
1346
+ function eventFilterSql(siteId, filters) {
1347
+ const values = [siteId, filters.start, filters.end];
1348
+ const clauses = [
1349
+ "e.site_id = $1",
1350
+ "e.occurred_at >= $2::timestamptz",
1351
+ "e.occurred_at < $3::timestamptz"
1352
+ ];
1353
+ const add = (sql, value) => {
1354
+ values.push(value);
1355
+ clauses.push(sql.replace("?", `$${values.length}`));
1356
+ };
1357
+ if (filters.pixelIds?.length)
1358
+ add("e.pixel_id = ANY(?::uuid[])", filters.pixelIds);
1359
+ if (filters.hostnames?.length)
1360
+ add("e.hostname = ANY(?::text[])", filters.hostnames);
1361
+ if (filters.source) add(`COALESCE(e.source, '(direct)') = ?`, filters.source);
1362
+ if (filters.medium) add(`COALESCE(e.medium, '(none)') = ?`, filters.medium);
1363
+ if (filters.campaign)
1364
+ add(`COALESCE(e.campaign, '(none)') = ?`, filters.campaign);
1365
+ if (filters.eventName) add("e.event_name = ?", filters.eventName);
1366
+ if (filters.channelFamily)
1367
+ add(`COALESCE(e.channel_family, 'other') = ?`, filters.channelFamily);
1368
+ if (filters.platform)
1369
+ add(`COALESCE(e.platform, e.source, '(direct)') = ?`, filters.platform);
1370
+ if (filters.referrer)
1371
+ add(`COALESCE(e.referrer, '') ILIKE '%' || ? || '%'`, filters.referrer);
1372
+ if (filters.path) add(`e.path = ?`, filters.path);
1373
+ if (filters.deviceClass)
1374
+ add(`COALESCE(e.device_class, 'unknown') = ?`, filters.deviceClass);
1375
+ if (filters.countryCode)
1376
+ add(
1377
+ `COALESCE(e.country_code, 'unknown') = ?`,
1378
+ filters.countryCode.toUpperCase()
1379
+ );
1380
+ if (filters.regionCode)
1381
+ add(
1382
+ `COALESCE(e.region_code, 'unknown') = ?`,
1383
+ filters.regionCode.toUpperCase()
1384
+ );
1385
+ return { where: clauses.join(" AND "), values };
1386
+ }
1387
+ function conversionFilterSql(siteId, filters) {
1388
+ const values = [siteId, filters.start, filters.end];
1389
+ const clauses = [
1390
+ "c.site_id = $1",
1391
+ "c.occurred_at >= $2::timestamptz",
1392
+ "c.occurred_at < $3::timestamptz"
1393
+ ];
1394
+ const add = (sql, value) => {
1395
+ values.push(value);
1396
+ clauses.push(sql.replaceAll("?", `$${values.length}`));
1397
+ };
1398
+ if (filters.pixelIds?.length)
1399
+ add("c.pixel_id = ANY(?::uuid[])", filters.pixelIds);
1400
+ if (filters.hostnames?.length)
1401
+ add(
1402
+ "EXISTS (SELECT 1 FROM analytics_events ce WHERE ce.site_id = c.site_id AND ce.session_id = c.session_id AND ce.hostname = ANY(?::text[]))",
1403
+ filters.hostnames
1404
+ );
1405
+ if (filters.source)
1406
+ add(`COALESCE(first_event.source, '(direct)') = ?`, filters.source);
1407
+ if (filters.medium)
1408
+ add(`COALESCE(first_event.medium, '(none)') = ?`, filters.medium);
1409
+ if (filters.campaign)
1410
+ add(`COALESCE(first_event.campaign, '(none)') = ?`, filters.campaign);
1411
+ if (filters.eventName)
1412
+ add(
1413
+ "EXISTS (SELECT 1 FROM analytics_events ce WHERE ce.site_id = c.site_id AND ce.session_id = c.session_id AND ce.event_name = ?)",
1414
+ filters.eventName
1415
+ );
1416
+ if (filters.channelFamily)
1417
+ add(
1418
+ `EXISTS (SELECT 1 FROM analytics_events ce WHERE ce.site_id = c.site_id AND ce.session_id = c.session_id AND COALESCE(ce.channel_family, 'other') = ?)`,
1419
+ filters.channelFamily
1420
+ );
1421
+ if (filters.platform)
1422
+ add(
1423
+ `EXISTS (SELECT 1 FROM analytics_events ce WHERE ce.site_id = c.site_id AND ce.session_id = c.session_id AND COALESCE(ce.platform, ce.source, '(direct)') = ?)`,
1424
+ filters.platform
1425
+ );
1426
+ if (filters.referrer)
1427
+ add(
1428
+ `EXISTS (SELECT 1 FROM analytics_events ce WHERE ce.site_id = c.site_id AND ce.session_id = c.session_id AND COALESCE(ce.referrer, '') ILIKE '%' || ? || '%')`,
1429
+ filters.referrer
1430
+ );
1431
+ if (filters.path)
1432
+ add(
1433
+ `EXISTS (SELECT 1 FROM analytics_events ce WHERE ce.site_id = c.site_id AND ce.session_id = c.session_id AND ce.path = ?)`,
1434
+ filters.path
1435
+ );
1436
+ if (filters.deviceClass)
1437
+ add(
1438
+ `EXISTS (SELECT 1 FROM analytics_events ce WHERE ce.site_id = c.site_id AND ce.session_id = c.session_id AND COALESCE(ce.device_class, 'unknown') = ?)`,
1439
+ filters.deviceClass
1440
+ );
1441
+ if (filters.countryCode)
1442
+ add(
1443
+ `EXISTS (SELECT 1 FROM analytics_events ce WHERE ce.site_id = c.site_id AND ce.session_id = c.session_id AND COALESCE(ce.country_code, 'unknown') = ?)`,
1444
+ filters.countryCode.toUpperCase()
1445
+ );
1446
+ if (filters.regionCode)
1447
+ add(
1448
+ `EXISTS (SELECT 1 FROM analytics_events ce WHERE ce.site_id = c.site_id AND ce.session_id = c.session_id AND COALESCE(ce.region_code, 'unknown') = ?)`,
1449
+ filters.regionCode.toUpperCase()
1450
+ );
1451
+ if (filters.conversionKind)
1452
+ add(`c.conversion_kind = ?`, filters.conversionKind);
1453
+ return { where: clauses.join(" AND "), values };
1454
+ }
1455
+ async function analyticsOverview(siteId, userId, filters) {
1456
+ const db = getAnalyticsPool();
1457
+ await accessFor(db, siteId, userId);
1458
+ const query = eventFilterSql(siteId, filters);
1459
+ const result = await db.query(
1460
+ `SELECT count(DISTINCT visitor_id)::int AS visitors,
1461
+ count(DISTINCT session_id)::int AS sessions,
1462
+ count(*) FILTER (WHERE event_name = 'page_view')::int AS pageviews,
1463
+ count(*)::int AS events,
1464
+ min(occurred_at)::text AS first_event_at,
1465
+ max(occurred_at)::text AS last_event_at
1466
+ FROM analytics_events e WHERE ${query.where}`,
1467
+ query.values
1468
+ );
1469
+ const conversionQuery = conversionFilterSql(siteId, filters);
1470
+ const conversions = await db.query(
1471
+ `SELECT count(*)::int AS conversions, COALESCE(sum(c.value_minor), 0)::int AS revenue_minor
1472
+ FROM analytics_conversions c
1473
+ LEFT JOIN LATERAL (
1474
+ SELECT e.source, e.medium, e.campaign FROM analytics_events e
1475
+ WHERE e.site_id = c.site_id AND e.session_id = c.session_id
1476
+ ORDER BY e.occurred_at ASC LIMIT 1
1477
+ ) first_event ON true
1478
+ WHERE ${conversionQuery.where}`,
1479
+ conversionQuery.values
1480
+ );
1481
+ const row = result.rows[0];
1482
+ const conversion = conversions.rows[0];
1483
+ return {
1484
+ ...row,
1485
+ conversions: conversion.conversions,
1486
+ revenueMinor: conversion.revenue_minor,
1487
+ conversionRate: row.sessions > 0 ? conversion.conversions / row.sessions : 0,
1488
+ dataFreshThrough: row.last_event_at
1489
+ };
1490
+ }
1491
+ async function analyticsTimeseries(siteId, userId, filters) {
1492
+ const db = getAnalyticsPool();
1493
+ await accessFor(db, siteId, userId);
1494
+ const query = eventFilterSql(siteId, filters);
1495
+ const timezone = filters.timezone || "UTC";
1496
+ const timezoneParameter = `$${query.values.length + 1}`;
1497
+ const result = await db.query(
1498
+ `SELECT date_trunc('day', e.occurred_at AT TIME ZONE ${timezoneParameter})::date::text AS date,
1499
+ count(DISTINCT e.visitor_id)::int AS visitors,
1500
+ count(DISTINCT e.session_id)::int AS sessions,
1501
+ count(*) FILTER (WHERE e.event_name = 'page_view')::int AS pageviews,
1502
+ count(*)::int AS events
1503
+ FROM analytics_events e
1504
+ WHERE ${query.where}
1505
+ GROUP BY 1 ORDER BY 1`,
1506
+ [...query.values, timezone]
1507
+ );
1508
+ const conversionQuery = conversionFilterSql(siteId, filters);
1509
+ const conversionTimezoneParameter = `$${conversionQuery.values.length + 1}`;
1510
+ const conversions = await db.query(
1511
+ `SELECT date_trunc('day', c.occurred_at AT TIME ZONE ${conversionTimezoneParameter})::date::text AS date,
1512
+ count(*)::int AS conversions,
1513
+ COALESCE(sum(c.value_minor), 0)::bigint AS revenue_minor
1514
+ FROM analytics_conversions c
1515
+ LEFT JOIN LATERAL (
1516
+ SELECT e.source, e.medium, e.campaign FROM analytics_events e
1517
+ WHERE e.site_id = c.site_id AND e.session_id = c.session_id
1518
+ ORDER BY e.occurred_at ASC LIMIT 1
1519
+ ) first_event ON true
1520
+ WHERE ${conversionQuery.where}
1521
+ GROUP BY 1 ORDER BY 1`,
1522
+ [...conversionQuery.values, timezone]
1523
+ );
1524
+ const byDate = /* @__PURE__ */ new Map();
1525
+ for (const row of result.rows)
1526
+ byDate.set(String(row.date), { ...row, conversions: 0, revenueMinor: 0 });
1527
+ for (const row of conversions.rows) {
1528
+ const current = byDate.get(row.date) ?? {
1529
+ date: row.date,
1530
+ visitors: 0,
1531
+ sessions: 0,
1532
+ pageviews: 0,
1533
+ events: 0
1534
+ };
1535
+ byDate.set(row.date, {
1536
+ ...current,
1537
+ conversions: row.conversions,
1538
+ revenueMinor: Number(row.revenue_minor)
1539
+ });
1540
+ }
1541
+ const points = [...byDate.values()].sort(
1542
+ (left, right) => String(left.date).localeCompare(String(right.date))
1543
+ );
1544
+ return { points, timezone, dataFreshThrough: points.at(-1)?.date ?? null };
1545
+ }
1546
+ async function analyticsAcquisition(siteId, userId, filters, page) {
1547
+ const db = getAnalyticsPool();
1548
+ await accessFor(db, siteId, userId);
1549
+ const query = eventFilterSql(siteId, filters);
1550
+ const result = await db.query(
1551
+ `SELECT COALESCE(e.source, '(direct)') AS source,
1552
+ COALESCE(e.medium, '(none)') AS medium,
1553
+ COALESCE(e.campaign, '(none)') AS campaign,
1554
+ count(DISTINCT e.visitor_id)::int AS visitors,
1555
+ count(DISTINCT e.session_id)::int AS sessions,
1556
+ count(*) FILTER (WHERE e.event_name = 'page_view')::int AS pageviews
1557
+ FROM analytics_events e
1558
+ WHERE ${query.where}
1559
+ GROUP BY 1, 2, 3
1560
+ ORDER BY sessions DESC, visitors DESC
1561
+ LIMIT 1000`,
1562
+ query.values
1563
+ );
1564
+ const values = [siteId, filters.start, filters.end];
1565
+ const touchClauses = [
1566
+ "c.site_id = $1",
1567
+ "c.occurred_at >= $2::timestamptz",
1568
+ "c.occurred_at < $3::timestamptz"
1569
+ ];
1570
+ const addTouch = (sql, value) => {
1571
+ values.push(value);
1572
+ touchClauses.push(sql.replace("?", `$${values.length}`));
1573
+ };
1574
+ if (filters.pixelIds?.length)
1575
+ addTouch("touch.pixel_id = ANY(?::uuid[])", filters.pixelIds);
1576
+ if (filters.hostnames?.length)
1577
+ addTouch("touch.hostname = ANY(?::text[])", filters.hostnames);
1578
+ if (filters.source)
1579
+ addTouch(`COALESCE(touch.source, '(direct)') = ?`, filters.source);
1580
+ if (filters.medium)
1581
+ addTouch(`COALESCE(touch.medium, '(none)') = ?`, filters.medium);
1582
+ if (filters.campaign)
1583
+ addTouch(`COALESCE(touch.campaign, '(none)') = ?`, filters.campaign);
1584
+ if (filters.channelFamily)
1585
+ addTouch(
1586
+ `COALESCE(touch.channel_family, 'other') = ?`,
1587
+ filters.channelFamily
1588
+ );
1589
+ if (filters.platform)
1590
+ addTouch(
1591
+ `COALESCE(touch.platform, touch.source, '(direct)') = ?`,
1592
+ filters.platform
1593
+ );
1594
+ if (filters.referrer)
1595
+ addTouch(
1596
+ `COALESCE(touch.referrer, '') ILIKE '%' || ? || '%'`,
1597
+ filters.referrer
1598
+ );
1599
+ if (filters.path) addTouch(`touch.path = ?`, filters.path);
1600
+ if (filters.deviceClass)
1601
+ addTouch(
1602
+ `COALESCE(touch.device_class, 'unknown') = ?`,
1603
+ filters.deviceClass
1604
+ );
1605
+ if (filters.countryCode)
1606
+ addTouch(
1607
+ `COALESCE(touch.country_code, 'unknown') = ?`,
1608
+ filters.countryCode.toUpperCase()
1609
+ );
1610
+ if (filters.regionCode)
1611
+ addTouch(
1612
+ `COALESCE(touch.region_code, 'unknown') = ?`,
1613
+ filters.regionCode.toUpperCase()
1614
+ );
1615
+ if (filters.conversionKind)
1616
+ addTouch(`c.conversion_kind = ?`, filters.conversionKind);
1617
+ const direction = filters.attributionModel === "last_touch" ? "DESC" : "ASC";
1618
+ const attributed = await db.query(
1619
+ `SELECT COALESCE(touch.source, '(direct)') AS source,
1620
+ COALESCE(touch.medium, '(none)') AS medium,
1621
+ COALESCE(touch.campaign, '(none)') AS campaign,
1622
+ count(*)::int AS conversions, COALESCE(sum(c.value_minor), 0)::bigint AS revenue_minor
1623
+ FROM analytics_conversions c
1624
+ JOIN LATERAL (
1625
+ SELECT e.* FROM analytics_events e
1626
+ WHERE e.site_id = c.site_id AND e.occurred_at <= c.occurred_at
1627
+ AND ((c.person_id IS NOT NULL AND e.person_id = c.person_id)
1628
+ OR (c.person_id IS NULL AND e.session_id = c.session_id))
1629
+ ORDER BY e.occurred_at ${direction} LIMIT 1
1630
+ ) touch ON true
1631
+ WHERE ${touchClauses.join(" AND ")}
1632
+ GROUP BY 1,2,3`,
1633
+ values
1634
+ );
1635
+ const outcomes = new Map(
1636
+ attributed.rows.map((row) => [
1637
+ `${row.source}\0${row.medium}\0${row.campaign}`,
1638
+ row
1639
+ ])
1640
+ );
1641
+ const rows = result.rows.map((row) => {
1642
+ const outcome = outcomes.get(
1643
+ `${row.source}\0${row.medium}\0${row.campaign}`
1644
+ );
1645
+ const conversions = Number(outcome?.conversions ?? 0);
1646
+ return {
1647
+ ...row,
1648
+ conversions,
1649
+ conversionRate: row.sessions > 0 ? conversions / row.sessions : 0,
1650
+ revenueMinor: Number(outcome?.revenue_minor ?? 0)
1651
+ };
1652
+ });
1653
+ for (const outcome of attributed.rows) {
1654
+ const key = `${outcome.source}\0${outcome.medium}\0${outcome.campaign}`;
1655
+ if (!result.rows.some(
1656
+ (row) => `${row.source}\0${row.medium}\0${row.campaign}` === key
1657
+ )) {
1658
+ rows.push({
1659
+ source: outcome.source,
1660
+ medium: outcome.medium,
1661
+ campaign: outcome.campaign,
1662
+ visitors: 0,
1663
+ sessions: 0,
1664
+ pageviews: 0,
1665
+ conversions: Number(outcome.conversions),
1666
+ conversionRate: 0,
1667
+ revenueMinor: Number(outcome.revenue_minor)
1668
+ });
1669
+ }
1670
+ }
1671
+ rows.sort(
1672
+ (a, b) => Number(b.sessions) - Number(a.sessions) || Number(b.conversions) - Number(a.conversions)
1673
+ );
1674
+ const paged = paginateRows(
1675
+ rows,
1676
+ page,
1677
+ pageFingerprint("acquisition", siteId, filters)
1678
+ );
1679
+ return {
1680
+ channels: paged.items,
1681
+ pageInfo: paged.pageInfo,
1682
+ dataFreshThrough: (/* @__PURE__ */ new Date()).toISOString()
1683
+ };
1684
+ }
1685
+ var inferredFamilySql = `COALESCE(e.channel_family, CASE
1686
+ WHEN lower(COALESCE(e.source,'') || ' ' || COALESCE(e.referrer,'')) ~ '(perplexity|chatgpt|openai|claude|anthropic|grok|xai|ai[_ -]?overview|google[_ -]?ai|gemini)' THEN 'llm'
1687
+ WHEN lower(COALESCE(e.source,'') || ' ' || COALESCE(e.referrer,'')) ~ '(facebook|instagram|linkedin|tiktok|youtube|youtu\\.be|twitter|x\\.com)' THEN 'social'
1688
+ WHEN lower(COALESCE(e.source,'') || ' ' || COALESCE(e.referrer,'')) ~ '(^|[^a-z])(g2|bbb)([^a-z]|$)|trustpilot|capterra|yelp|better business bureau' THEN 'review'
1689
+ WHEN COALESCE(e.source,'') = '' THEN 'direct'
1690
+ WHEN lower(COALESCE(e.medium,'')) ~ 'email|newsletter' THEN 'email'
1691
+ ELSE 'other' END)`;
1692
+ async function analyticsChannelBreakdown(siteId, userId, filters) {
1693
+ const db = getAnalyticsPool();
1694
+ await accessFor(db, siteId, userId);
1695
+ const query = eventFilterSql(siteId, filters);
1696
+ const result = await db.query(
1697
+ `
1698
+ SELECT ${inferredFamilySql} AS family,
1699
+ COALESCE(e.platform, NULLIF(e.source,''), '(direct)') AS platform,
1700
+ COALESCE(NULLIF(e.campaign,''), '(none)') AS campaign,
1701
+ COALESCE(NULLIF(e.campaign_id,''), '(unknown)') AS campaign_id,
1702
+ COALESCE(NULLIF(e.ad_set_id,''), '(unknown)') AS ad_set_id,
1703
+ COALESCE(NULLIF(e.ad_id,''), '(unknown)') AS ad_id,
1704
+ COALESCE(NULLIF(e.creative_id,''), '(unknown)') AS creative_id,
1705
+ count(DISTINCT e.visitor_id)::int AS visitors,
1706
+ count(DISTINCT e.session_id)::int AS sessions,
1707
+ count(*) FILTER (WHERE e.event_name = 'page_view')::int AS pageviews,
1708
+ count(*)::int AS events
1709
+ FROM analytics_events e
1710
+ WHERE ${query.where}
1711
+ GROUP BY 1,2,3,4,5,6,7
1712
+ ORDER BY sessions DESC, events DESC
1713
+ LIMIT 2000`,
1714
+ query.values
1715
+ );
1716
+ const families = ["llm", "social", "review"];
1717
+ const sections = Object.fromEntries(
1718
+ families.map((family) => [
1719
+ family,
1720
+ result.rows.filter((row) => row.family === family)
1721
+ ])
1722
+ );
1723
+ return {
1724
+ sections,
1725
+ rows: result.rows,
1726
+ dataFreshThrough: (/* @__PURE__ */ new Date()).toISOString()
1727
+ };
1728
+ }
1729
+ async function analyticsContent(siteId, userId, filters, page) {
1730
+ const db = getAnalyticsPool();
1731
+ await accessFor(db, siteId, userId);
1732
+ const query = eventFilterSql(siteId, filters);
1733
+ const result = await db.query(
1734
+ `WITH filtered AS (
1735
+ SELECT e.* FROM analytics_events e WHERE ${query.where}
1736
+ ), page_metrics AS (
1737
+ SELECT path, count(DISTINCT visitor_id)::int AS visitors,
1738
+ count(DISTINCT session_id)::int AS sessions,
1739
+ count(*) FILTER (WHERE event_name = 'page_view')::int AS pageviews,
1740
+ count(*) FILTER (WHERE event_name <> 'page_view')::int AS engagement_events,
1741
+ count(*)::int AS events
1742
+ FROM filtered GROUP BY path
1743
+ ), session_bounds AS (
1744
+ SELECT session_id,
1745
+ (array_agg(path ORDER BY occurred_at ASC, id ASC))[1] AS entrance_path,
1746
+ (array_agg(path ORDER BY occurred_at DESC, id DESC))[1] AS exit_path
1747
+ FROM filtered WHERE session_id IS NOT NULL GROUP BY session_id
1748
+ ), entrances AS (
1749
+ SELECT entrance_path AS path, count(*)::int AS entrances FROM session_bounds GROUP BY entrance_path
1750
+ ), exits AS (
1751
+ SELECT exit_path AS path, count(*)::int AS exits FROM session_bounds GROUP BY exit_path
1752
+ ), assisted AS (
1753
+ SELECT f.path, count(DISTINCT c.id)::int AS conversions
1754
+ FROM analytics_conversions c JOIN filtered f ON f.session_id = c.session_id
1755
+ WHERE c.site_id = $1 AND c.occurred_at >= $2::timestamptz AND c.occurred_at < $3::timestamptz
1756
+ GROUP BY f.path
1757
+ )
1758
+ SELECT metrics.*, COALESCE(entrances.entrances, 0)::int AS entrances,
1759
+ COALESCE(exits.exits, 0)::int AS exits,
1760
+ COALESCE(assisted.conversions, 0)::int AS conversions
1761
+ FROM page_metrics metrics
1762
+ LEFT JOIN entrances USING(path)
1763
+ LEFT JOIN exits USING(path)
1764
+ LEFT JOIN assisted USING(path)
1765
+ ORDER BY metrics.pageviews DESC, metrics.events DESC, metrics.path ASC
1766
+ LIMIT 1000`,
1767
+ query.values
1768
+ );
1769
+ const paged = paginateRows(
1770
+ result.rows,
1771
+ page,
1772
+ pageFingerprint("content", siteId, filters)
1773
+ );
1774
+ return {
1775
+ content: paged.items,
1776
+ pageInfo: paged.pageInfo,
1777
+ dataFreshThrough: (/* @__PURE__ */ new Date()).toISOString()
1778
+ };
1779
+ }
1780
+ async function analyticsEventCounts(siteId, userId, filters, page) {
1781
+ const db = getAnalyticsPool();
1782
+ await accessFor(db, siteId, userId);
1783
+ const query = eventFilterSql(siteId, filters);
1784
+ const [result, samples] = await Promise.all([
1785
+ db.query(
1786
+ `SELECT e.event_name AS name, count(*)::int AS count, max(e.occurred_at)::text AS last_seen_at
1787
+ FROM analytics_events e
1788
+ WHERE ${query.where}
1789
+ GROUP BY e.event_name ORDER BY count DESC, e.event_name ASC
1790
+ LIMIT 1000`,
1791
+ query.values
1792
+ ),
1793
+ db.query(
1794
+ `SELECT e.event_id, e.event_name, e.path, e.hostname,
1795
+ COALESCE(e.source, '(direct)') AS source,
1796
+ COALESCE(e.campaign, '(none)') AS campaign,
1797
+ e.occurred_at::text
1798
+ FROM analytics_events e WHERE ${query.where}
1799
+ ORDER BY e.occurred_at DESC, e.id DESC LIMIT 25`,
1800
+ query.values
1801
+ )
1802
+ ]);
1803
+ const receiptValues = [siteId, filters.start, filters.end];
1804
+ const receiptClauses = [
1805
+ "r.site_id = $1",
1806
+ "r.created_at >= $2::timestamptz",
1807
+ "r.created_at < $3::timestamptz"
1808
+ ];
1809
+ const addReceipt = (clause, value) => {
1810
+ receiptValues.push(value);
1811
+ receiptClauses.push(clause.replace("?", `$${receiptValues.length}`));
1812
+ };
1813
+ if (filters.pixelIds?.length)
1814
+ addReceipt("r.pixel_id = ANY(?::uuid[])", filters.pixelIds);
1815
+ if (filters.hostnames?.length)
1816
+ addReceipt("r.hostname = ANY(?::text[])", filters.hostnames);
1817
+ const rejections = await db.query(
1818
+ `SELECT reason.code, count(*)::int AS requests,
1819
+ COALESCE(sum(r.rejected_count), 0)::int AS rejected_events,
1820
+ max(r.created_at)::text AS last_seen_at
1821
+ FROM analytics_ingestion_receipts r
1822
+ CROSS JOIN LATERAL jsonb_array_elements_text(r.reason_codes) AS reason(code)
1823
+ WHERE ${receiptClauses.join(" AND ")} AND r.rejected_count > 0
1824
+ GROUP BY reason.code ORDER BY rejected_events DESC, reason.code ASC`,
1825
+ receiptValues
1826
+ );
1827
+ const paged = paginateRows(
1828
+ result.rows,
1829
+ page,
1830
+ pageFingerprint("events", siteId, filters)
1831
+ );
1832
+ return {
1833
+ events: paged.items,
1834
+ samples: samples.rows,
1835
+ rejections: rejections.rows,
1836
+ pageInfo: paged.pageInfo,
1837
+ dataFreshThrough: samples.rows[0]?.occurred_at ?? null
1838
+ };
1839
+ }
1840
+ async function analyticsConversions(siteId, userId, filters, page) {
1841
+ const db = getAnalyticsPool();
1842
+ await accessFor(db, siteId, userId);
1843
+ const query = conversionFilterSql(siteId, filters);
1844
+ const result = await db.query(
1845
+ `SELECT c.id, c.source_event_id, c.pixel_id, c.session_id, c.conversion_kind,
1846
+ c.value_minor::int, c.currency, c.occurred_at::text,
1847
+ COALESCE(first_event.source, '(direct)') AS source,
1848
+ COALESCE(first_event.medium, '(none)') AS medium,
1849
+ COALESCE(first_event.campaign, '(none)') AS campaign
1850
+ FROM analytics_conversions c
1851
+ LEFT JOIN LATERAL (
1852
+ SELECT e.source, e.medium, e.campaign
1853
+ FROM analytics_events e
1854
+ WHERE e.site_id = c.site_id AND e.session_id = c.session_id
1855
+ ORDER BY e.occurred_at ASC LIMIT 1
1856
+ ) first_event ON true
1857
+ WHERE ${query.where}
1858
+ ORDER BY c.occurred_at DESC
1859
+ LIMIT 1000`,
1860
+ query.values
1861
+ );
1862
+ const summary = result.rows.reduce(
1863
+ (current, row) => ({
1864
+ count: current.count + 1,
1865
+ valueMinor: current.valueMinor + Number(row.value_minor ?? 0)
1866
+ }),
1867
+ { count: 0, valueMinor: 0 }
1868
+ );
1869
+ const paged = paginateRows(
1870
+ result.rows,
1871
+ page,
1872
+ pageFingerprint("conversions", siteId, filters)
1873
+ );
1874
+ return {
1875
+ conversions: paged.items,
1876
+ pageInfo: paged.pageInfo,
1877
+ summary,
1878
+ dataFreshThrough: result.rows[0]?.occurred_at ?? null
1879
+ };
1880
+ }
1881
+ async function analyticsPaths(siteId, userId, filters, page) {
1882
+ const db = getAnalyticsPool();
1883
+ await accessFor(db, siteId, userId);
1884
+ const query = conversionFilterSql(siteId, filters);
1885
+ const result = await db.query(
1886
+ `SELECT c.session_id, c.person_id,
1887
+ string_agg(COALESCE(path_event.platform, path_event.source, '(direct)') || ' / ' || COALESCE(path_event.medium, '(none)') ||
1888
+ CASE WHEN path_event.campaign IS NOT NULL THEN ' / ' || path_event.campaign ELSE '' END ||
1889
+ CASE WHEN path_event.ad_id IS NOT NULL THEN ' / ad:' || path_event.ad_id ELSE '' END,
1890
+ ' \u2192 ' ORDER BY path_event.occurred_at) AS path,
1891
+ count(path_event.id)::int AS touchpoints,
1892
+ 1::int AS conversions,
1893
+ c.value_minor::int AS value_minor
1894
+ FROM analytics_conversions c
1895
+ LEFT JOIN LATERAL (
1896
+ SELECT e.source, e.medium, e.campaign FROM analytics_events e
1897
+ WHERE e.site_id = c.site_id AND e.session_id = c.session_id
1898
+ ORDER BY e.occurred_at ASC LIMIT 1
1899
+ ) first_event ON true
1900
+ JOIN analytics_events path_event ON path_event.site_id = c.site_id AND
1901
+ ((c.person_id IS NOT NULL AND path_event.person_id = c.person_id) OR (c.person_id IS NULL AND path_event.session_id = c.session_id))
1902
+ WHERE ${query.where}
1903
+ GROUP BY c.id
1904
+ ORDER BY conversions DESC, value_minor DESC
1905
+ LIMIT 1000`,
1906
+ query.values
1907
+ );
1908
+ const paged = paginateRows(
1909
+ result.rows,
1910
+ page,
1911
+ pageFingerprint("paths", siteId, filters)
1912
+ );
1913
+ return {
1914
+ paths: paged.items,
1915
+ pageInfo: paged.pageInfo,
1916
+ dataFreshThrough: (/* @__PURE__ */ new Date()).toISOString()
1917
+ };
1918
+ }
1919
+ async function analyticsDimensions(siteId, userId, filters, dimension, page) {
1920
+ const db = getAnalyticsPool();
1921
+ await accessFor(db, siteId, userId);
1922
+ const query = eventFilterSql(siteId, filters);
1923
+ const select = dimension === "device" ? `COALESCE(NULLIF(e.device_class, ''), 'unknown') AS key, COALESCE(NULLIF(e.device_class, ''), 'Unknown') AS label` : dimension === "source" ? `COALESCE(NULLIF(e.source, ''), '(direct)') AS key, COALESCE(NULLIF(e.source, ''), '(direct)') AS label` : dimension === "country" ? `COALESCE(NULLIF(e.country_code, ''), 'unknown') AS key, COALESCE(NULLIF(e.country_code, ''), 'Unknown') AS label` : dimension === "region" ? `COALESCE(NULLIF(e.region_code, ''), 'unknown') AS key, COALESCE(NULLIF(e.region_code, ''), 'Unknown') AS label` : `extract(dow from e.occurred_at)::int::text || '-' || extract(hour from e.occurred_at)::int::text AS key,
1924
+ to_char(e.occurred_at, 'Dy') || ' ' || lpad(extract(hour from e.occurred_at)::int::text, 2, '0') || ':00' AS label`;
1925
+ const result = await db.query(
1926
+ `SELECT ${select},
1927
+ count(DISTINCT e.visitor_id)::int AS visitors,
1928
+ count(DISTINCT e.session_id)::int AS sessions,
1929
+ count(*) FILTER (WHERE e.event_name = 'page_view')::int AS pageviews,
1930
+ count(*)::int AS events,
1931
+ count(*)::int AS value
1932
+ FROM analytics_events e
1933
+ WHERE ${query.where}
1934
+ GROUP BY 1, 2
1935
+ ORDER BY value DESC, key ASC
1936
+ LIMIT 1000`,
1937
+ query.values
1938
+ );
1939
+ const paged = paginateRows(
1940
+ result.rows,
1941
+ page,
1942
+ pageFingerprint(`dimension:${dimension}`, siteId, filters)
1943
+ );
1944
+ const chartRecommendation = dimension === "device" ? "donut" : dimension === "weekday_hour" ? "heatmap" : dimension === "country" || dimension === "region" ? "choropleth_or_bar" : "horizontal_bar";
1945
+ return {
1946
+ dimension,
1947
+ unit: "events",
1948
+ chartRecommendation,
1949
+ rows: paged.items,
1950
+ pageInfo: paged.pageInfo,
1951
+ dataFreshThrough: (/* @__PURE__ */ new Date()).toISOString()
1952
+ };
1953
+ }
1954
+ function buildTaggedCampaignUrl(row) {
1955
+ const url = new URL(row.destination_url);
1956
+ for (const [key, value] of [
1957
+ ["utm_source", row.utm_source],
1958
+ ["utm_medium", row.utm_medium],
1959
+ ["utm_campaign", row.utm_campaign],
1960
+ ["utm_term", row.utm_term],
1961
+ ["utm_content", row.utm_content],
1962
+ ["ad_group", row.ad_group],
1963
+ ["ad_name", row.ad_name],
1964
+ ["creative_id", row.creative_id]
1965
+ ])
1966
+ if (value) url.searchParams.set(key, value);
1967
+ return url.toString();
1968
+ }
1969
+ function campaignLinkWithUrl(row) {
1970
+ return { ...row, tagged_url: buildTaggedCampaignUrl(row) };
1971
+ }
1972
+ async function createAnalyticsCampaignLink(input) {
1973
+ const db = getAnalyticsPool();
1974
+ await requireEditor(db, input.siteId, input.userId);
1975
+ let destination;
1976
+ try {
1977
+ destination = new URL(input.destinationUrl);
1978
+ } catch {
1979
+ throw new AnalyticsRepositoryError(
1980
+ "invalid_destination_url",
1981
+ "Enter a valid destination URL."
1982
+ );
1983
+ }
1984
+ if (!["http:", "https:"].includes(destination.protocol))
1985
+ throw new AnalyticsRepositoryError(
1986
+ "invalid_destination_url",
1987
+ "Destination URL must use HTTP or HTTPS."
1988
+ );
1989
+ if (input.pixelId) {
1990
+ const pixel = await db.query(
1991
+ "SELECT 1 FROM analytics_pixels WHERE id = $1 AND site_id = $2",
1992
+ [input.pixelId, input.siteId]
1993
+ );
1994
+ if (!pixel.rowCount)
1995
+ throw new AnalyticsRepositoryError(
1996
+ "analytics_pixel_not_found",
1997
+ "Pixel not found.",
1998
+ 404
1999
+ );
2000
+ }
2001
+ const shortCode = (input.shortCode?.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-") || randomBytes(5).toString("base64url").toLowerCase()).slice(0, 48);
2002
+ try {
2003
+ const result = await db.query(
2004
+ `INSERT INTO analytics_campaign_links(
2005
+ id, site_id, pixel_id, name, destination_url, short_code, utm_source, utm_medium,
2006
+ utm_campaign, utm_term, utm_content, ad_group, ad_name, creative_id, created_by_user_id
2007
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
2008
+ RETURNING *, 0::int AS click_count`,
2009
+ [
2010
+ randomUUID(),
2011
+ input.siteId,
2012
+ input.pixelId ?? null,
2013
+ input.name.trim(),
2014
+ destination.toString(),
2015
+ shortCode,
2016
+ input.source.trim(),
2017
+ input.medium.trim(),
2018
+ input.campaign.trim(),
2019
+ input.term?.trim() || null,
2020
+ input.content?.trim() || null,
2021
+ input.adGroup?.trim() || null,
2022
+ input.adName?.trim() || null,
2023
+ input.creativeId?.trim() || null,
2024
+ input.userId
2025
+ ]
2026
+ );
2027
+ return campaignLinkWithUrl(result.rows[0]);
2028
+ } catch (error) {
2029
+ if (error.code === "23505")
2030
+ throw new AnalyticsRepositoryError(
2031
+ "campaign_short_code_taken",
2032
+ "That short code is already in use.",
2033
+ 409
2034
+ );
2035
+ throw error;
2036
+ }
2037
+ }
2038
+ async function listAnalyticsCampaignLinks(siteId, userId, page) {
2039
+ const db = getAnalyticsPool();
2040
+ await accessFor(db, siteId, userId);
2041
+ const result = await db.query(
2042
+ `SELECT l.*, count(c.id)::int AS click_count
2043
+ FROM analytics_campaign_links l LEFT JOIN analytics_campaign_clicks c ON c.link_id = l.id
2044
+ WHERE l.site_id = $1
2045
+ GROUP BY l.id ORDER BY l.created_at DESC, l.id DESC LIMIT 1000`,
2046
+ [siteId]
2047
+ );
2048
+ const rows = result.rows.map(campaignLinkWithUrl);
2049
+ const filters = { start: "all", end: "all" };
2050
+ const paged = paginateRows(
2051
+ rows,
2052
+ page,
2053
+ pageFingerprint("campaign-links", siteId, filters)
2054
+ );
2055
+ return { links: paged.items, pageInfo: paged.pageInfo };
2056
+ }
2057
+ async function archiveAnalyticsCampaignLink(input) {
2058
+ const db = getAnalyticsPool();
2059
+ await requireEditor(db, input.siteId, input.userId);
2060
+ const result = await db.query(
2061
+ `UPDATE analytics_campaign_links SET status = 'archived', updated_at = now() WHERE id = $1 AND site_id = $2`,
2062
+ [input.linkId, input.siteId]
2063
+ );
2064
+ if (!result.rowCount)
2065
+ throw new AnalyticsRepositoryError(
2066
+ "campaign_link_not_found",
2067
+ "Campaign link not found.",
2068
+ 404
2069
+ );
2070
+ }
2071
+ async function resolveAnalyticsCampaignLink(shortCode, referrer) {
2072
+ const db = getAnalyticsPool();
2073
+ const result = await db.query(
2074
+ `SELECT l.*, 0::int AS click_count FROM analytics_campaign_links l WHERE l.short_code = $1 AND l.status = 'active'`,
2075
+ [shortCode]
2076
+ );
2077
+ const row = result.rows[0];
2078
+ if (!row) return null;
2079
+ await db.query(
2080
+ `INSERT INTO analytics_campaign_clicks(id, link_id, referrer) VALUES ($1, $2, $3)`,
2081
+ [randomUUID(), row.id, normalizeAnalyticsUrl(referrer || void 0)]
2082
+ );
2083
+ return buildTaggedCampaignUrl(row);
2084
+ }
2085
+ async function createAnalyticsForm(input) {
2086
+ const db = getAnalyticsPool();
2087
+ await requireEditor(db, input.siteId, input.userId);
2088
+ const pixel = await db.query(
2089
+ "SELECT 1 FROM analytics_pixels WHERE id = $1 AND site_id = $2",
2090
+ [input.pixelId, input.siteId]
2091
+ );
2092
+ if (!pixel.rowCount)
2093
+ throw new AnalyticsRepositoryError(
2094
+ "analytics_pixel_not_found",
2095
+ "Pixel not found.",
2096
+ 404
2097
+ );
2098
+ const baseSlug = normalizeSlug(input.name);
2099
+ const slug = `${baseSlug}-${randomBytes(3).toString("hex")}`;
2100
+ const publicId = `form_${randomBytes(18).toString("base64url")}`;
2101
+ const result = await db.query(
2102
+ `INSERT INTO analytics_forms(id, public_id, site_id, pixel_id, name, slug, fields, brand, submit_label, success_message, redirect_url, consent_text, status, created_by_user_id)
2103
+ VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9,$10,$11,$12,$13,$14)
2104
+ RETURNING *, 0::int AS submission_count`,
2105
+ [
2106
+ randomUUID(),
2107
+ publicId,
2108
+ input.siteId,
2109
+ input.pixelId,
2110
+ input.name.trim(),
2111
+ slug,
2112
+ JSON.stringify(input.fields),
2113
+ JSON.stringify(input.brand),
2114
+ input.submitLabel?.trim() || "Submit",
2115
+ input.successMessage?.trim() || "Thank you. Your response was received.",
2116
+ input.redirectUrl?.trim() || null,
2117
+ input.consentText?.trim() || null,
2118
+ input.publish ? "published" : "draft",
2119
+ input.userId
2120
+ ]
2121
+ );
2122
+ return result.rows[0];
2123
+ }
2124
+ async function listAnalyticsForms(siteId, userId, page) {
2125
+ const db = getAnalyticsPool();
2126
+ await accessFor(db, siteId, userId);
2127
+ const result = await db.query(
2128
+ `SELECT f.*, count(s.id)::int AS submission_count FROM analytics_forms f
2129
+ LEFT JOIN analytics_form_submissions s ON s.form_id = f.id
2130
+ WHERE f.site_id = $1 GROUP BY f.id ORDER BY f.created_at DESC, f.id DESC LIMIT 1000`,
2131
+ [siteId]
2132
+ );
2133
+ const paged = paginateRows(
2134
+ result.rows,
2135
+ page,
2136
+ pageFingerprint("forms", siteId, { start: "all", end: "all" })
2137
+ );
2138
+ return { forms: paged.items, pageInfo: paged.pageInfo };
2139
+ }
2140
+ async function getPublicAnalyticsForm(publicId) {
2141
+ const result = await getAnalyticsPool().query(
2142
+ `SELECT f.*, s.owner_user_id, p.public_id AS pixel_public_id,
2143
+ (SELECT count(*)::int FROM analytics_form_submissions fs WHERE fs.form_id = f.id) AS submission_count
2144
+ FROM analytics_forms f JOIN analytics_sites s ON s.id = f.site_id JOIN analytics_pixels p ON p.id = f.pixel_id
2145
+ WHERE f.public_id = $1 AND f.status = 'published'`,
2146
+ [publicId]
2147
+ );
2148
+ return result.rows[0] ?? null;
2149
+ }
2150
+ async function recordAnalyticsFormSubmission(input) {
2151
+ const id = randomUUID();
2152
+ await getAnalyticsPool().query(
2153
+ `INSERT INTO analytics_form_submissions(id, form_id, site_id, pixel_id, visitor_id, session_id, crm_person_ref, source, medium, campaign, crm_delivery_status, click_ids)
2154
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb)`,
2155
+ [
2156
+ id,
2157
+ input.form.id,
2158
+ input.form.site_id,
2159
+ input.form.pixel_id,
2160
+ input.visitorId ?? null,
2161
+ input.sessionId ?? null,
2162
+ input.crmPersonRef ?? null,
2163
+ input.source?.slice(0, 180) ?? null,
2164
+ input.medium?.slice(0, 180) ?? null,
2165
+ input.campaign?.slice(0, 240) ?? null,
2166
+ input.crmDeliveryStatus ?? "pending",
2167
+ JSON.stringify(sanitizeClickIds(input.clickIds))
2168
+ ]
2169
+ );
2170
+ return { submissionId: id };
2171
+ }
2172
+ var clickIdKeys = /* @__PURE__ */ new Set([
2173
+ "fbclid",
2174
+ "gclid",
2175
+ "gbraid",
2176
+ "wbraid",
2177
+ "ttclid",
2178
+ "rdt_cid",
2179
+ "msclkid"
2180
+ ]);
2181
+ function sanitizeClickIds(value) {
2182
+ const output = {};
2183
+ for (const [key, raw] of Object.entries(value ?? {})) {
2184
+ if (!clickIdKeys.has(key) || typeof raw !== "string") continue;
2185
+ const normalized = raw.trim();
2186
+ if (normalized) output[key] = normalized.slice(0, 500);
2187
+ }
2188
+ return output;
2189
+ }
2190
+ function identityHmac(kind, value) {
2191
+ return createHmac2("sha256", getSessionSecret()).update(`${kind}:${value.trim().toLowerCase()}`).digest("hex");
2192
+ }
2193
+ async function linkAnalyticsFormIdentity(input) {
2194
+ const client = await getAnalyticsPool().connect();
2195
+ try {
2196
+ await client.query("BEGIN");
2197
+ const person = await client.query(
2198
+ `INSERT INTO analytics_people(id, site_id, crm_person_ref) VALUES ($1,$2,$3)
2199
+ ON CONFLICT(site_id, crm_person_ref) DO UPDATE SET last_seen_at = now() RETURNING id`,
2200
+ [randomUUID(), input.siteId, input.crmPersonRef]
2201
+ );
2202
+ const personId = person.rows[0].id;
2203
+ const signals = [];
2204
+ if (input.visitorId)
2205
+ signals.push({
2206
+ kind: "visitor_id",
2207
+ value: input.visitorId,
2208
+ evidence: "first_party_form",
2209
+ confidence: 0.98
2210
+ });
2211
+ if (input.sessionId)
2212
+ signals.push({
2213
+ kind: "session_id",
2214
+ value: input.sessionId,
2215
+ evidence: "first_party_form",
2216
+ confidence: 0.95
2217
+ });
2218
+ if (input.email)
2219
+ signals.push({
2220
+ kind: "email",
2221
+ value: input.email,
2222
+ evidence: "submitted_email",
2223
+ confidence: 1
2224
+ });
2225
+ if (input.phone)
2226
+ signals.push({
2227
+ kind: "phone",
2228
+ value: input.phone,
2229
+ evidence: "submitted_phone",
2230
+ confidence: 0.99
2231
+ });
2232
+ for (const [platform, clickId] of Object.entries(
2233
+ sanitizeClickIds(input.clickIds)
2234
+ )) {
2235
+ signals.push({
2236
+ kind: "click_id",
2237
+ value: `${platform}:${clickId}`,
2238
+ evidence: platform,
2239
+ confidence: 0.9
2240
+ });
2241
+ }
2242
+ for (const signal of signals) {
2243
+ const node = await client.query(
2244
+ `INSERT INTO analytics_identity_nodes(id, site_id, kind, value_hmac) VALUES ($1,$2,$3,$4)
2245
+ ON CONFLICT(site_id, kind, value_hmac) DO UPDATE SET last_seen_at = now() RETURNING id`,
2246
+ [
2247
+ randomUUID(),
2248
+ input.siteId,
2249
+ signal.kind,
2250
+ identityHmac(signal.kind, signal.value)
2251
+ ]
2252
+ );
2253
+ await client.query(
2254
+ `INSERT INTO analytics_identity_edges(id, site_id, person_id, identity_node_id, evidence_kind, confidence)
2255
+ VALUES ($1,$2,$3,$4,$5,$6)
2256
+ ON CONFLICT(person_id, identity_node_id, evidence_kind) DO UPDATE SET last_seen_at = now(), confidence = greatest(analytics_identity_edges.confidence, EXCLUDED.confidence)`,
2257
+ [
2258
+ randomUUID(),
2259
+ input.siteId,
2260
+ personId,
2261
+ node.rows[0].id,
2262
+ signal.evidence,
2263
+ signal.confidence
2264
+ ]
2265
+ );
2266
+ }
2267
+ if (input.submissionId)
2268
+ await client.query(
2269
+ "UPDATE analytics_form_submissions SET person_id = $1 WHERE id = $2 AND site_id = $3",
2270
+ [personId, input.submissionId, input.siteId]
2271
+ );
2272
+ if (input.visitorId || input.sessionId) {
2273
+ await client.query(
2274
+ `UPDATE analytics_events SET person_id = $1 WHERE site_id = $2 AND person_id IS NULL
2275
+ AND (($3::text IS NOT NULL AND visitor_id = $3) OR ($4::text IS NOT NULL AND session_id = $4))`,
2276
+ [
2277
+ personId,
2278
+ input.siteId,
2279
+ input.visitorId ?? null,
2280
+ input.sessionId ?? null
2281
+ ]
2282
+ );
2283
+ await client.query(
2284
+ `UPDATE analytics_conversions SET person_id = $1 WHERE site_id = $2 AND person_id IS NULL AND $3::text IS NOT NULL AND session_id = $3`,
2285
+ [personId, input.siteId, input.sessionId ?? null]
2286
+ );
2287
+ }
2288
+ await client.query("COMMIT");
2289
+ return { personId, linkedSignals: signals.length };
2290
+ } catch (error) {
2291
+ await client.query("ROLLBACK");
2292
+ throw error;
2293
+ } finally {
2294
+ client.release();
2295
+ }
2296
+ }
2297
+ async function listAnalyticsPeople(siteId, userId, page) {
2298
+ const db = getAnalyticsPool();
2299
+ await accessFor(db, siteId, userId);
2300
+ const result = await db.query(
2301
+ `SELECT p.id, p.first_seen_at, p.last_seen_at, true AS crm_linked,
2302
+ (SELECT count(*)::int FROM analytics_identity_edges e WHERE e.person_id = p.id) AS signal_count,
2303
+ (SELECT count(*)::int FROM analytics_form_submissions f WHERE f.person_id = p.id) AS form_submission_count,
2304
+ (SELECT count(*)::int FROM analytics_conversions c WHERE c.person_id = p.id) AS conversion_count,
2305
+ (SELECT COALESCE(sum(c.value_minor), 0)::bigint FROM analytics_conversions c WHERE c.person_id = p.id) AS revenue_minor
2306
+ FROM analytics_people p WHERE p.site_id = $1 ORDER BY p.last_seen_at DESC, p.id DESC LIMIT 1000`,
2307
+ [siteId]
2308
+ );
2309
+ const paged = paginateRows(
2310
+ result.rows,
2311
+ page,
2312
+ pageFingerprint("people", siteId, { start: "all", end: "all" })
2313
+ );
2314
+ return { people: paged.items, pageInfo: paged.pageInfo };
2315
+ }
2316
+ async function getAnalyticsPersonJourney(siteId, personId, userId, page) {
2317
+ const db = getAnalyticsPool();
2318
+ await accessFor(db, siteId, userId);
2319
+ const exists = await db.query(
2320
+ "SELECT 1 FROM analytics_people WHERE id = $1 AND site_id = $2",
2321
+ [personId, siteId]
2322
+ );
2323
+ if (!exists.rowCount)
2324
+ throw new AnalyticsRepositoryError(
2325
+ "analytics_person_not_found",
2326
+ "Attributed person not found.",
2327
+ 404
2328
+ );
2329
+ const result = await db.query(
2330
+ `SELECT * FROM (
2331
+ SELECT e.occurred_at, 'event'::text AS kind, e.event_name AS label, e.source, e.medium, e.campaign, e.path, 0::bigint AS value_minor, NULL::text AS currency
2332
+ FROM analytics_events e WHERE e.site_id = $1 AND e.person_id = $2
2333
+ UNION ALL
2334
+ SELECT f.occurred_at, 'form_submission', af.name, f.source, f.medium, f.campaign, NULL, 0, NULL
2335
+ FROM analytics_form_submissions f JOIN analytics_forms af ON af.id = f.form_id WHERE f.site_id = $1 AND f.person_id = $2
2336
+ UNION ALL
2337
+ SELECT c.occurred_at, 'conversion', c.conversion_kind, NULL, NULL, NULL, NULL, c.value_minor, c.currency
2338
+ FROM analytics_conversions c WHERE c.site_id = $1 AND c.person_id = $2
2339
+ ) journey ORDER BY occurred_at DESC LIMIT 1000`,
2340
+ [siteId, personId]
2341
+ );
2342
+ const paged = paginateRows(
2343
+ result.rows,
2344
+ page,
2345
+ pageFingerprint("person_journey", siteId, {
2346
+ start: personId,
2347
+ end: personId
2348
+ })
2349
+ );
2350
+ return { personId, touchpoints: paged.items, pageInfo: paged.pageInfo };
2351
+ }
2352
+ async function createAnalyticsCrmImport(input) {
2353
+ const db = getAnalyticsPool();
2354
+ await requireEditor(db, input.siteId, input.userId);
2355
+ const client = await db.connect();
2356
+ const id = randomUUID();
2357
+ try {
2358
+ await client.query("BEGIN");
2359
+ await client.query(
2360
+ `INSERT INTO analytics_crm_imports(id, site_id, requested_by_user_id, source_system, filename, accepted_count, rejected_count)
2361
+ VALUES ($1,$2,$3,$4,$5,$6,$7)`,
2362
+ [
2363
+ id,
2364
+ input.siteId,
2365
+ input.userId,
2366
+ input.sourceSystem.slice(0, 80),
2367
+ input.filename.slice(0, 240),
2368
+ input.rows.length,
2369
+ input.rejectedCount
2370
+ ]
2371
+ );
2372
+ for (const row of input.rows) {
2373
+ await client.query(
2374
+ `INSERT INTO analytics_crm_import_rows(id, import_id, crm_person_ref, payload_ciphertext)
2375
+ VALUES ($1,$2,$3,$4) ON CONFLICT(import_id, crm_person_ref) DO NOTHING`,
2376
+ [randomUUID(), id, row.crmPersonRef, row.payloadCiphertext]
2377
+ );
2378
+ }
2379
+ await client.query("COMMIT");
2380
+ return {
2381
+ id,
2382
+ status: "queued",
2383
+ acceptedCount: input.rows.length,
2384
+ rejectedCount: input.rejectedCount
2385
+ };
2386
+ } catch (error) {
2387
+ await client.query("ROLLBACK");
2388
+ throw error;
2389
+ } finally {
2390
+ client.release();
2391
+ }
2392
+ }
2393
+ async function listAnalyticsCrmImports(siteId, userId, page) {
2394
+ const db = getAnalyticsPool();
2395
+ await accessFor(db, siteId, userId);
2396
+ const result = await db.query(
2397
+ `SELECT id, source_system, filename, status, accepted_count, rejected_count, created_at, completed_at
2398
+ FROM analytics_crm_imports WHERE site_id = $1 ORDER BY created_at DESC, id DESC LIMIT 1000`,
2399
+ [siteId]
2400
+ );
2401
+ const paged = paginateRows(
2402
+ result.rows,
2403
+ page,
2404
+ pageFingerprint("crm_imports", siteId, { start: "all", end: "all" })
2405
+ );
2406
+ return { imports: paged.items, pageInfo: paged.pageInfo };
2407
+ }
2408
+ async function createAnalyticsActivationDestination(input) {
2409
+ const db = getAnalyticsPool();
2410
+ await requireEditor(db, input.siteId, input.userId);
2411
+ const result = await db.query(
2412
+ `INSERT INTO analytics_activation_destinations(id, site_id, platform, name, connection_ref, external_dataset_id, event_mapping, created_by_user_id)
2413
+ VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8) RETURNING *`,
2414
+ [
2415
+ randomUUID(),
2416
+ input.siteId,
2417
+ input.platform,
2418
+ input.name.trim(),
2419
+ input.connectionRef?.trim() || null,
2420
+ input.externalDatasetId?.trim() || null,
2421
+ JSON.stringify(input.eventMapping ?? {}),
2422
+ input.userId
2423
+ ]
2424
+ );
2425
+ return result.rows[0];
2426
+ }
2427
+ async function listAnalyticsActivationDestinations(siteId, userId) {
2428
+ const db = getAnalyticsPool();
2429
+ await accessFor(db, siteId, userId);
2430
+ const result = await db.query(
2431
+ `SELECT d.*, count(j.id)::int AS delivery_count,
2432
+ count(j.id) FILTER (WHERE j.status = 'pending')::int AS pending_count,
2433
+ count(j.id) FILTER (WHERE j.status = 'delivered')::int AS delivered_count,
2434
+ count(j.id) FILTER (WHERE j.status = 'failed')::int AS failed_count
2435
+ FROM analytics_activation_destinations d LEFT JOIN analytics_activation_jobs j ON j.destination_id = d.id
2436
+ WHERE d.site_id = $1 AND d.status <> 'archived' GROUP BY d.id ORDER BY d.created_at DESC`,
2437
+ [siteId]
2438
+ );
2439
+ return result.rows;
2440
+ }
2441
+ async function archiveAnalyticsActivationDestination(input) {
2442
+ const db = getAnalyticsPool();
2443
+ await requireEditor(db, input.siteId, input.userId);
2444
+ const result = await db.query(
2445
+ `UPDATE analytics_activation_destinations SET status = 'archived', updated_at = now() WHERE id = $1 AND site_id = $2`,
2446
+ [input.destinationId, input.siteId]
2447
+ );
2448
+ if (!result.rowCount)
2449
+ throw new AnalyticsRepositoryError(
2450
+ "analytics_activation_destination_not_found",
2451
+ "Activation destination not found.",
2452
+ 404
2453
+ );
2454
+ }
2455
+ async function queueAnalyticsActivation(input) {
2456
+ const db = getAnalyticsPool();
2457
+ const destinations = await db.query(
2458
+ `SELECT id FROM analytics_activation_destinations WHERE site_id = $1 AND status = 'active'`,
2459
+ [input.siteId]
2460
+ );
2461
+ let queued = 0;
2462
+ for (const destination of destinations.rows) {
2463
+ const result = await db.query(
2464
+ `INSERT INTO analytics_activation_jobs(id, destination_id, conversion_id, person_id, payload_ciphertext)
2465
+ VALUES ($1,$2,$3,$4,$5) ON CONFLICT(destination_id, conversion_id) DO NOTHING`,
2466
+ [
2467
+ randomUUID(),
2468
+ destination.id,
2469
+ input.conversionId,
2470
+ input.personId ?? null,
2471
+ input.payloadCiphertext
2472
+ ]
2473
+ );
2474
+ queued += result.rowCount ?? 0;
2475
+ }
2476
+ return queued;
2477
+ }
2478
+ async function queueAnalyticsFormDelivery(input) {
2479
+ const id = randomUUID();
2480
+ await getAnalyticsPool().query(
2481
+ `INSERT INTO analytics_form_delivery_jobs(id, submission_id, owner_user_id, payload_ciphertext, last_error_code)
2482
+ VALUES ($1,$2,$3,$4,$5)`,
2483
+ [
2484
+ id,
2485
+ input.submissionId,
2486
+ input.ownerUserId,
2487
+ input.payloadCiphertext,
2488
+ input.errorCode ?? null
2489
+ ]
2490
+ );
2491
+ return id;
2492
+ }
2493
+ async function claimAnalyticsFormDeliveryJobs(limit = 10) {
2494
+ const result = await getAnalyticsPool().query(
2495
+ `WITH due AS (
2496
+ SELECT id FROM analytics_form_delivery_jobs
2497
+ WHERE status = 'pending' AND next_attempt_at <= now() AND expires_at > now()
2498
+ ORDER BY next_attempt_at, created_at
2499
+ FOR UPDATE SKIP LOCKED LIMIT $1
2500
+ )
2501
+ UPDATE analytics_form_delivery_jobs j
2502
+ SET attempts = j.attempts + 1,
2503
+ next_attempt_at = now() + interval '5 minutes'
2504
+ FROM due WHERE j.id = due.id
2505
+ RETURNING j.id, j.submission_id, j.owner_user_id, j.payload_ciphertext, j.attempts`,
2506
+ [Math.min(Math.max(limit, 1), 50)]
2507
+ );
2508
+ return result.rows.map((row) => ({
2509
+ id: row.id,
2510
+ submissionId: row.submission_id,
2511
+ ownerUserId: Number(row.owner_user_id),
2512
+ payloadCiphertext: row.payload_ciphertext,
2513
+ attempts: row.attempts
2514
+ }));
2515
+ }
2516
+ async function completeAnalyticsFormDelivery(jobId, submissionId) {
2517
+ const db = getAnalyticsPool();
2518
+ const client = await db.connect();
2519
+ try {
2520
+ await client.query("BEGIN");
2521
+ await client.query(
2522
+ `UPDATE analytics_form_delivery_jobs SET status='delivered', delivered_at=now(), last_error_code=NULL WHERE id=$1`,
2523
+ [jobId]
2524
+ );
2525
+ await client.query(
2526
+ `UPDATE analytics_form_submissions SET crm_delivery_status='delivered' WHERE id=$1`,
2527
+ [submissionId]
2528
+ );
2529
+ await client.query("COMMIT");
2530
+ } catch (error) {
2531
+ await client.query("ROLLBACK");
2532
+ throw error;
2533
+ } finally {
2534
+ client.release();
2535
+ }
2536
+ }
2537
+ async function deferAnalyticsFormDelivery(jobId, attempts, errorCode) {
2538
+ const terminal = attempts >= 8;
2539
+ const delayMinutes = Math.min(5 * 2 ** Math.max(0, attempts - 1), 360);
2540
+ await getAnalyticsPool().query(
2541
+ `UPDATE analytics_form_delivery_jobs
2542
+ SET status = CASE WHEN expires_at <= now() THEN 'expired' WHEN $2 THEN 'failed' ELSE 'pending' END,
2543
+ last_error_code = $3,
2544
+ next_attempt_at = now() + ($4::text || ' minutes')::interval
2545
+ WHERE id = $1`,
2546
+ [jobId, terminal, errorCode.slice(0, 80), String(delayMinutes)]
2547
+ );
2548
+ }
2549
+ async function isAnalyticsFormPlacementApproved(pixelId, placementUrl) {
2550
+ if (!placementUrl) return false;
2551
+ let hostname = null;
2552
+ try {
2553
+ hostname = new URL(placementUrl).hostname.toLowerCase();
2554
+ } catch {
2555
+ return false;
2556
+ }
2557
+ const result = await getAnalyticsPool().query(
2558
+ `SELECT 1 FROM analytics_pixel_domains WHERE pixel_id = $1 AND hostname = $2 AND state = 'approved'`,
2559
+ [pixelId, hostname]
2560
+ );
2561
+ return Boolean(result.rowCount);
2562
+ }
2563
+ async function analyticsHealth(siteId, userId) {
2564
+ const db = getAnalyticsPool();
2565
+ const pixels = await listAnalyticsPixels(siteId, userId);
2566
+ const lastEventAt = pixels.map((pixel) => pixel.last_event_at).filter(Boolean).sort().at(-1) ?? null;
2567
+ const [conversion, rollup, receipts] = await Promise.all([
2568
+ db.query(
2569
+ `SELECT max(occurred_at)::text AS last_conversion_at FROM analytics_conversions WHERE site_id = $1`,
2570
+ [siteId]
2571
+ ),
2572
+ db.query(
2573
+ `SELECT max(refreshed_at)::text AS last_rollup_at FROM analytics_rollups_daily WHERE site_id = $1`,
2574
+ [siteId]
2575
+ ),
2576
+ db.query(
2577
+ `SELECT COALESCE(sum(accepted_count), 0)::int AS accepted,
2578
+ COALESCE(sum(rejected_count), 0)::int AS rejected
2579
+ FROM analytics_ingestion_receipts WHERE site_id = $1 AND created_at >= now() - interval '24 hours'`,
2580
+ [siteId]
2581
+ )
2582
+ ]);
2583
+ const recent = receipts.rows[0];
2584
+ const totalRecent = recent.accepted + recent.rejected;
2585
+ const rejectionRate = totalRecent ? recent.rejected / totalRecent : 0;
2586
+ const stale = Boolean(
2587
+ lastEventAt && Date.now() - Date.parse(lastEventAt) > 48 * 60 * 60 * 1e3
2588
+ );
2589
+ const hasMismatch = pixels.some(
2590
+ (pixel) => pixel.domains.some((domain) => domain.state === "observed_unverified")
2591
+ );
2592
+ return {
2593
+ status: pixels.length === 0 ? "not_installed" : hasMismatch && !lastEventAt ? "domain_mismatch" : rejectionRate > 0.25 ? "rejecting" : stale ? "stale" : lastEventAt ? "healthy" : "waiting",
2594
+ pixels,
2595
+ lastEventAt,
2596
+ lastConversionAt: conversion.rows[0]?.last_conversion_at ?? null,
2597
+ lastRollupAt: rollup.rows[0]?.last_rollup_at ?? null,
2598
+ acceptedLast24Hours: recent.accepted,
2599
+ rejectedLast24Hours: recent.rejected,
2600
+ rejectionRate,
2601
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString()
2602
+ };
2603
+ }
2604
+ async function refreshAnalyticsDailyRollups(input) {
2605
+ const db = getAnalyticsPool();
2606
+ const runId = randomUUID();
2607
+ await db.query(
2608
+ `INSERT INTO analytics_rollup_runs(id, window_start, window_end, status) VALUES ($1, $2, $3, 'running')`,
2609
+ [runId, input.start, input.end]
2610
+ );
2611
+ const client = await db.connect();
2612
+ try {
2613
+ await client.query("BEGIN");
2614
+ await client.query(
2615
+ `DELETE FROM analytics_rollups_daily WHERE bucket >= $1::date AND bucket < $2::date`,
2616
+ [input.start, input.end]
2617
+ );
2618
+ const inserted = await client.query(
2619
+ `INSERT INTO analytics_rollups_daily(
2620
+ site_id, pixel_id, hostname, bucket, source, medium, campaign,
2621
+ pageviews, events, visitors, sessions, refreshed_at
2622
+ )
2623
+ SELECT site_id, pixel_id, hostname, occurred_at::date,
2624
+ COALESCE(source, '(direct)'), COALESCE(medium, '(none)'), COALESCE(campaign, '(none)'),
2625
+ count(*) FILTER (WHERE event_name = 'page_view')::int,
2626
+ count(*)::int,
2627
+ count(DISTINCT visitor_id)::int,
2628
+ count(DISTINCT session_id)::int,
2629
+ now()
2630
+ FROM analytics_events
2631
+ WHERE occurred_at >= $1::timestamptz AND occurred_at < $2::timestamptz
2632
+ GROUP BY 1, 2, 3, 4, 5, 6, 7`,
2633
+ [input.start, input.end]
2634
+ );
2635
+ await client.query("COMMIT");
2636
+ const rows = inserted.rowCount ?? 0;
2637
+ await db.query(
2638
+ `UPDATE analytics_rollup_runs SET status = 'complete', row_count = $2, completed_at = now() WHERE id = $1`,
2639
+ [runId, rows]
2640
+ );
2641
+ return { runId, rows };
2642
+ } catch (error) {
2643
+ await client.query("ROLLBACK");
2644
+ await db.query(
2645
+ `UPDATE analytics_rollup_runs SET status = 'failed', completed_at = now(), error_code = 'rollup_failed' WHERE id = $1`,
2646
+ [runId]
2647
+ );
2648
+ throw error;
2649
+ } finally {
2650
+ client.release();
2651
+ }
2652
+ }
2653
+ async function refreshAnalyticsDailyRollupsIfDue(now = /* @__PURE__ */ new Date()) {
2654
+ const db = getAnalyticsPool();
2655
+ const lockClient = await db.connect();
2656
+ const advisoryLockId = 2026080501;
2657
+ let locked = false;
2658
+ try {
2659
+ const lock = await lockClient.query(
2660
+ "SELECT pg_try_advisory_lock($1) AS acquired",
2661
+ [advisoryLockId]
2662
+ );
2663
+ locked = Boolean(lock.rows[0]?.acquired);
2664
+ if (!locked) return { status: "already_running", rows: 0 };
2665
+ const recent = await lockClient.query(
2666
+ `SELECT completed_at FROM analytics_rollup_runs
2667
+ WHERE status = 'complete' AND completed_at > $1::timestamptz
2668
+ ORDER BY completed_at DESC LIMIT 1`,
2669
+ [new Date(now.getTime() - 55 * 6e4).toISOString()]
2670
+ );
2671
+ if (recent.rows[0]) return { status: "not_due", rows: 0 };
2672
+ const end = new Date(now);
2673
+ const start = new Date(end.getTime() - 3 * 24 * 60 * 60 * 1e3);
2674
+ const result = await refreshAnalyticsDailyRollups({
2675
+ start: start.toISOString(),
2676
+ end: end.toISOString()
2677
+ });
2678
+ return { status: "complete", ...result };
2679
+ } finally {
2680
+ if (locked)
2681
+ await lockClient.query("SELECT pg_advisory_unlock($1)", [advisoryLockId]).catch(() => void 0);
2682
+ lockClient.release();
2683
+ }
2684
+ }
2685
+ function csvCell(value) {
2686
+ const text = value == null ? "" : String(value);
2687
+ return /[\n\r,\"]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
2688
+ }
2689
+ function rowsToCsv(rows) {
2690
+ const columns = [...new Set(rows.flatMap((row) => Object.keys(row)))];
2691
+ if (!columns.length) return "";
2692
+ return [
2693
+ columns.join(","),
2694
+ ...rows.map(
2695
+ (row) => columns.map((column) => csvCell(row[column])).join(",")
2696
+ )
2697
+ ].join("\n");
2698
+ }
2699
+ async function createAnalyticsExport(input) {
2700
+ const site = (await listAnalyticsSites(input.userId)).find(
2701
+ (candidate) => candidate.id === input.siteId
2702
+ );
2703
+ if (!site)
2704
+ throw new AnalyticsRepositoryError(
2705
+ "analytics_site_not_found",
2706
+ "Site not found.",
2707
+ 404
2708
+ );
2709
+ const describeArtifact = (artifact2) => {
2710
+ const extension = artifact2.format === "markdown" ? "md" : artifact2.format;
2711
+ return {
2712
+ ...artifact2,
2713
+ mediaType: artifact2.format === "csv" ? "text/csv" : artifact2.format === "json" ? "application/json" : "text/markdown",
2714
+ filename: `${site.slug}-${artifact2.report}.${extension}`
2715
+ };
2716
+ };
2717
+ if (input.idempotencyKey) {
2718
+ const existing = await getAnalyticsPool().query(
2719
+ `SELECT id, report, format, content FROM analytics_exports
2720
+ WHERE requested_by_user_id = $1 AND idempotency_key = $2`,
2721
+ [input.userId, input.idempotencyKey]
2722
+ );
2723
+ const artifact2 = existing.rows[0];
2724
+ if (artifact2) return describeArtifact(artifact2);
2725
+ }
2726
+ const data = input.report === "overview" ? await analyticsOverview(input.siteId, input.userId, input.filters) : input.report === "acquisition" ? await analyticsAcquisition(input.siteId, input.userId, input.filters) : input.report === "content" ? await analyticsContent(input.siteId, input.userId, input.filters) : await analyticsConversions(
2727
+ input.siteId,
2728
+ input.userId,
2729
+ input.filters
2730
+ );
2731
+ const rows = input.report === "overview" ? [data] : input.report === "acquisition" ? data.channels : input.report === "content" ? data.content : data.conversions;
2732
+ let content;
2733
+ if (input.format === "json") {
2734
+ content = JSON.stringify(
2735
+ {
2736
+ version: 1,
2737
+ site: { id: site.id, name: site.name },
2738
+ report: input.report,
2739
+ filters: input.filters,
2740
+ data
2741
+ },
2742
+ null,
2743
+ 2
2744
+ );
2745
+ } else if (input.format === "csv") {
2746
+ content = rowsToCsv(rows);
2747
+ } else {
2748
+ const overview = await analyticsOverview(
2749
+ input.siteId,
2750
+ input.userId,
2751
+ input.filters
2752
+ );
2753
+ content = [
2754
+ `# ${site.name} Site Analytics`,
2755
+ "",
2756
+ `Report: ${input.report}`,
2757
+ `Window: ${input.filters.start} to ${input.filters.end}`,
2758
+ `Fresh through: ${overview.dataFreshThrough ?? "No accepted events"}`,
2759
+ "",
2760
+ `- Visitors: ${overview.visitors}`,
2761
+ `- Sessions: ${overview.sessions}`,
2762
+ `- Pageviews: ${overview.pageviews}`,
2763
+ `- Verified conversions: ${overview.conversions}`,
2764
+ `- Verified revenue: ${(overview.revenueMinor / 100).toFixed(2)}`,
2765
+ "",
2766
+ `Generated ${(/* @__PURE__ */ new Date()).toISOString()} from the governed ${input.report} report contract.`
2767
+ ].join("\n");
2768
+ }
2769
+ const id = randomUUID();
2770
+ const inserted = await getAnalyticsPool().query(
2771
+ `INSERT INTO analytics_exports(id, site_id, requested_by_user_id, idempotency_key, report, format, filters, content)
2772
+ VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8)
2773
+ ON CONFLICT (requested_by_user_id, idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING
2774
+ RETURNING id, report, format, content`,
2775
+ [
2776
+ id,
2777
+ input.siteId,
2778
+ input.userId,
2779
+ input.idempotencyKey ?? null,
2780
+ input.report,
2781
+ input.format,
2782
+ JSON.stringify(input.filters),
2783
+ content
2784
+ ]
2785
+ );
2786
+ const artifact = inserted.rows[0];
2787
+ if (artifact) return describeArtifact(artifact);
2788
+ const replay = await getAnalyticsPool().query(
2789
+ `SELECT id, report, format, content FROM analytics_exports
2790
+ WHERE requested_by_user_id = $1 AND idempotency_key = $2`,
2791
+ [input.userId, input.idempotencyKey]
2792
+ );
2793
+ if (!replay.rows[0]) {
2794
+ throw new AnalyticsRepositoryError(
2795
+ "analytics_export_conflict",
2796
+ "The export could not be created or replayed.",
2797
+ 409
2798
+ );
2799
+ }
2800
+ return describeArtifact(replay.rows[0]);
2801
+ }
2802
+
2803
+ export {
2804
+ getSessionSecret,
2805
+ signSession,
2806
+ verifySession,
2807
+ AnalyticsRepositoryError,
2808
+ getAnalyticsPool,
2809
+ closeAnalyticsPool,
2810
+ migrateAnalytics,
2811
+ normalizeObservedHostname,
2812
+ createAnalyticsSite,
2813
+ listAnalyticsSites,
2814
+ updateAnalyticsBusinessModel,
2815
+ upsertAnalyticsAdSpend,
2816
+ analyticsBusinessMetrics,
2817
+ analyticsForecast,
2818
+ deleteAnalyticsSite,
2819
+ createAnalyticsPixel,
2820
+ listAnalyticsPixels,
2821
+ updateAnalyticsPixel,
2822
+ setAnalyticsPixelDomainState,
2823
+ normalizeAnalyticsPath,
2824
+ normalizeAnalyticsUrl,
2825
+ sanitizeAnalyticsProperties,
2826
+ ingestAnalyticsEvents,
2827
+ createAnalyticsConversion,
2828
+ analyticsOverview,
2829
+ analyticsTimeseries,
2830
+ analyticsAcquisition,
2831
+ analyticsChannelBreakdown,
2832
+ analyticsContent,
2833
+ analyticsEventCounts,
2834
+ analyticsConversions,
2835
+ analyticsPaths,
2836
+ analyticsDimensions,
2837
+ createAnalyticsCampaignLink,
2838
+ listAnalyticsCampaignLinks,
2839
+ archiveAnalyticsCampaignLink,
2840
+ resolveAnalyticsCampaignLink,
2841
+ createAnalyticsForm,
2842
+ listAnalyticsForms,
2843
+ getPublicAnalyticsForm,
2844
+ recordAnalyticsFormSubmission,
2845
+ sanitizeClickIds,
2846
+ linkAnalyticsFormIdentity,
2847
+ listAnalyticsPeople,
2848
+ getAnalyticsPersonJourney,
2849
+ createAnalyticsCrmImport,
2850
+ listAnalyticsCrmImports,
2851
+ createAnalyticsActivationDestination,
2852
+ listAnalyticsActivationDestinations,
2853
+ archiveAnalyticsActivationDestination,
2854
+ queueAnalyticsActivation,
2855
+ queueAnalyticsFormDelivery,
2856
+ claimAnalyticsFormDeliveryJobs,
2857
+ completeAnalyticsFormDelivery,
2858
+ deferAnalyticsFormDelivery,
2859
+ isAnalyticsFormPlacementApproved,
2860
+ analyticsHealth,
2861
+ refreshAnalyticsDailyRollups,
2862
+ refreshAnalyticsDailyRollupsIfDue,
2863
+ createAnalyticsExport
2864
+ };
2865
+ //# sourceMappingURL=chunk-YW2LXLDU.js.map