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.
- package/README.md +2 -2
- package/dist/analytics-repository-WWXVU3VW.js +119 -0
- package/dist/bin/api-server.cjs +7905 -1911
- package/dist/bin/api-server.cjs.map +1 -1
- package/dist/bin/api-server.js +8 -4
- package/dist/bin/api-server.js.map +1 -1
- package/dist/bin/mcp-scraper-cli.cjs +1 -1
- package/dist/bin/mcp-scraper-cli.cjs.map +1 -1
- package/dist/bin/mcp-scraper-cli.js +1 -1
- package/dist/bin/mcp-scraper-install.cjs +3 -3
- package/dist/bin/mcp-scraper-install.cjs.map +1 -1
- package/dist/bin/mcp-scraper-install.js +2 -2
- package/dist/bin/mcp-stdio-server.cjs +1856 -1029
- package/dist/bin/mcp-stdio-server.cjs.map +1 -1
- package/dist/bin/mcp-stdio-server.js +5 -5
- package/dist/bin/paa-harvest.cjs.map +1 -1
- package/dist/bin/paa-harvest.js +3 -3
- package/dist/{chunk-XCGQBGDA.js → chunk-64WBUDPC.js} +2125 -1262
- package/dist/chunk-64WBUDPC.js.map +1 -0
- package/dist/{chunk-MW3ET532.js → chunk-6DPIE262.js} +2 -2
- package/dist/{chunk-JH7A4HA7.js → chunk-MFE6RMIA.js} +2 -2
- package/dist/{chunk-5S6LYQQO.js → chunk-MFNGUM4L.js} +2 -2
- package/dist/chunk-MFNGUM4L.js.map +1 -0
- package/dist/{chunk-IWYYZXBR.js → chunk-PODASLGT.js} +2 -2
- package/dist/{chunk-QSUGPYGQ.js → chunk-QH63IZTX.js} +3 -3
- package/dist/chunk-QH63IZTX.js.map +1 -0
- package/dist/{chunk-TV6KPZ6J.js → chunk-R66PJOZW.js} +2 -2
- package/dist/chunk-XLEDHV3J.js +7 -0
- package/dist/chunk-XLEDHV3J.js.map +1 -0
- package/dist/chunk-YW2LXLDU.js +2865 -0
- package/dist/chunk-YW2LXLDU.js.map +1 -0
- package/dist/{chunk-GV5EGE5M.js → chunk-ZEMHL36J.js} +2 -2
- package/dist/{db-W3RPRILW.js → db-A4YUPV4Z.js} +2 -2
- package/dist/{extract-bundle-DXWWQER6.js → extract-bundle-2HONVC5T.js} +3 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +3 -3
- package/dist/{location-data-repository-2FJVMTZ2.js → location-data-repository-YWFJGNIP.js} +3 -3
- package/dist/{server-OHMZJRSB.js → server-JE7SLZLJ.js} +2605 -346
- package/dist/server-JE7SLZLJ.js.map +1 -0
- package/dist/{site-extract-repository-UULQAGO4.js → site-extract-repository-STNOI5OH.js} +3 -3
- package/dist/site-extract-repository-STNOI5OH.js.map +1 -0
- package/dist/{worker-3QX7LG62.js → worker-4MHZS3OB.js} +5 -5
- package/package.json +3 -1
- package/dist/chunk-5S6LYQQO.js.map +0 -1
- package/dist/chunk-H4NWAF5B.js +0 -7
- package/dist/chunk-H4NWAF5B.js.map +0 -1
- package/dist/chunk-QSUGPYGQ.js.map +0 -1
- package/dist/chunk-XCGQBGDA.js.map +0 -1
- package/dist/server-OHMZJRSB.js.map +0 -1
- /package/dist/{db-W3RPRILW.js.map → analytics-repository-WWXVU3VW.js.map} +0 -0
- /package/dist/{chunk-MW3ET532.js.map → chunk-6DPIE262.js.map} +0 -0
- /package/dist/{chunk-JH7A4HA7.js.map → chunk-MFE6RMIA.js.map} +0 -0
- /package/dist/{chunk-IWYYZXBR.js.map → chunk-PODASLGT.js.map} +0 -0
- /package/dist/{chunk-TV6KPZ6J.js.map → chunk-R66PJOZW.js.map} +0 -0
- /package/dist/{chunk-GV5EGE5M.js.map → chunk-ZEMHL36J.js.map} +0 -0
- /package/dist/{location-data-repository-2FJVMTZ2.js.map → db-A4YUPV4Z.js.map} +0 -0
- /package/dist/{extract-bundle-DXWWQER6.js.map → extract-bundle-2HONVC5T.js.map} +0 -0
- /package/dist/{site-extract-repository-UULQAGO4.js.map → location-data-repository-YWFJGNIP.js.map} +0 -0
- /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
|