mcp-scraper 0.67.0 → 0.68.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.
@@ -1,5 +1,10 @@
1
+ import {
2
+ getDb,
3
+ getUserByEmail
4
+ } from "./chunk-65FTMB7G.js";
5
+
1
6
  // src/api/analytics-repository.ts
2
- import { createHash, createHmac as createHmac2, randomBytes, randomUUID } from "crypto";
7
+ import { createHash, createHmac as createHmac2, randomBytes, randomUUID as randomUUID2 } from "crypto";
3
8
  import { Pool } from "pg";
4
9
 
5
10
  // src/api/session.ts
@@ -111,6 +116,823 @@ function resolveAnalyticsAttribution(input) {
111
116
  };
112
117
  }
113
118
 
119
+ // src/api/service-connections.ts
120
+ import { randomUUID } from "crypto";
121
+ function serviceConnectionAdvertisesAction(connection, providerConfigKeys, actionName) {
122
+ return connection.lifecycleStatus === "connected" && connection.actionsEnabled && providerConfigKeys.includes(connection.providerConfigKey) && connection.actionTools.includes(actionName);
123
+ }
124
+ var schemaReady = null;
125
+ var schemaDb = null;
126
+ function ensureServiceConnectionsSchema() {
127
+ const currentDb = getDb();
128
+ if (schemaReady && schemaDb === currentDb) return schemaReady;
129
+ schemaDb = currentDb;
130
+ schemaReady = (async () => {
131
+ const db = currentDb;
132
+ await db.execute(`
133
+ CREATE TABLE IF NOT EXISTS service_connections (
134
+ id TEXT PRIMARY KEY,
135
+ user_id INTEGER NOT NULL REFERENCES users(id),
136
+ provider_config_key TEXT NOT NULL,
137
+ provider TEXT,
138
+ transport TEXT NOT NULL,
139
+ upstream_connection_id TEXT,
140
+ label TEXT,
141
+ user_label TEXT,
142
+ provider_account_id TEXT,
143
+ provider_account_email TEXT,
144
+ provider_account_name TEXT,
145
+ provider_identity_checked_at TEXT,
146
+ provider_identity_source_updated_at TEXT,
147
+ lifecycle_status TEXT NOT NULL DEFAULT 'pending',
148
+ operational_status TEXT NOT NULL DEFAULT 'unknown',
149
+ reconnect_required INTEGER NOT NULL DEFAULT 0,
150
+ actions_enabled INTEGER NOT NULL DEFAULT 0,
151
+ read_tools_json TEXT NOT NULL DEFAULT '[]',
152
+ action_tools_json TEXT NOT NULL DEFAULT '[]',
153
+ tool_revision TEXT,
154
+ last_checked_at TEXT,
155
+ last_successful_call_at TEXT,
156
+ last_failure_at TEXT,
157
+ last_failure_code TEXT,
158
+ last_failure_retryable INTEGER,
159
+ consecutive_failures INTEGER NOT NULL DEFAULT 0,
160
+ vault_name TEXT,
161
+ table_name TEXT,
162
+ legacy_owner_identity TEXT,
163
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
164
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
165
+ UNIQUE(user_id, provider_config_key, upstream_connection_id)
166
+ )
167
+ `);
168
+ try {
169
+ await db.execute(`ALTER TABLE service_connections ADD COLUMN user_label TEXT`);
170
+ } catch {
171
+ }
172
+ try {
173
+ await db.execute(`ALTER TABLE service_connections ADD COLUMN provider_account_id TEXT`);
174
+ } catch {
175
+ }
176
+ try {
177
+ await db.execute(`ALTER TABLE service_connections ADD COLUMN provider_account_email TEXT`);
178
+ } catch {
179
+ }
180
+ try {
181
+ await db.execute(`ALTER TABLE service_connections ADD COLUMN provider_account_name TEXT`);
182
+ } catch {
183
+ }
184
+ try {
185
+ await db.execute(`ALTER TABLE service_connections ADD COLUMN provider_identity_checked_at TEXT`);
186
+ } catch {
187
+ }
188
+ try {
189
+ await db.execute(`ALTER TABLE service_connections ADD COLUMN provider_identity_source_updated_at TEXT`);
190
+ } catch {
191
+ }
192
+ await db.execute(`CREATE INDEX IF NOT EXISTS service_connections_user_updated ON service_connections(user_id, updated_at DESC)`);
193
+ await db.execute(`CREATE INDEX IF NOT EXISTS service_connections_upstream ON service_connections(provider_config_key, upstream_connection_id)`);
194
+ await db.execute(`
195
+ CREATE TABLE IF NOT EXISTS service_connection_health_events (
196
+ id TEXT PRIMARY KEY,
197
+ connection_id TEXT NOT NULL REFERENCES service_connections(id) ON DELETE CASCADE,
198
+ operational_status TEXT NOT NULL,
199
+ failure_code TEXT,
200
+ retryable INTEGER,
201
+ evidence_source TEXT NOT NULL,
202
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
203
+ )
204
+ `);
205
+ await db.execute(`CREATE INDEX IF NOT EXISTS service_connection_health_events_connection ON service_connection_health_events(connection_id, created_at DESC)`);
206
+ await db.execute(`
207
+ CREATE TABLE IF NOT EXISTS service_connection_action_audit (
208
+ id TEXT PRIMARY KEY,
209
+ connection_id TEXT NOT NULL REFERENCES service_connections(id) ON DELETE CASCADE,
210
+ user_id INTEGER NOT NULL REFERENCES users(id),
211
+ tool TEXT NOT NULL,
212
+ request_id TEXT NOT NULL,
213
+ status TEXT NOT NULL,
214
+ error_code TEXT,
215
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
216
+ UNIQUE(user_id, request_id)
217
+ )
218
+ `);
219
+ try {
220
+ await db.execute(`ALTER TABLE service_connection_action_audit ADD COLUMN request_digest TEXT`);
221
+ } catch {
222
+ }
223
+ try {
224
+ await db.execute(`ALTER TABLE service_connection_action_audit ADD COLUMN result_json TEXT`);
225
+ } catch {
226
+ }
227
+ })().catch((error) => {
228
+ schemaReady = null;
229
+ schemaDb = null;
230
+ throw error;
231
+ });
232
+ return schemaReady;
233
+ }
234
+ function parseTools(value) {
235
+ if (typeof value !== "string") return [];
236
+ try {
237
+ const parsed = JSON.parse(value);
238
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : [];
239
+ } catch {
240
+ return [];
241
+ }
242
+ }
243
+ function rowToRecord(row) {
244
+ return {
245
+ id: String(row.id),
246
+ userId: Number(row.user_id),
247
+ providerConfigKey: String(row.provider_config_key),
248
+ provider: typeof row.provider === "string" ? row.provider : null,
249
+ transport: row.transport === "remote_mcp" ? "remote_mcp" : "nango",
250
+ upstreamConnectionId: typeof row.upstream_connection_id === "string" ? row.upstream_connection_id : null,
251
+ label: typeof row.label === "string" ? row.label : null,
252
+ userLabel: typeof row.user_label === "string" ? row.user_label : null,
253
+ providerAccountId: typeof row.provider_account_id === "string" ? row.provider_account_id : null,
254
+ providerAccountEmail: typeof row.provider_account_email === "string" ? row.provider_account_email : null,
255
+ providerAccountName: typeof row.provider_account_name === "string" ? row.provider_account_name : null,
256
+ providerIdentityCheckedAt: typeof row.provider_identity_checked_at === "string" ? row.provider_identity_checked_at : null,
257
+ providerIdentitySourceUpdatedAt: typeof row.provider_identity_source_updated_at === "string" ? row.provider_identity_source_updated_at : null,
258
+ lifecycleStatus: String(row.lifecycle_status),
259
+ operationalStatus: String(row.operational_status),
260
+ reconnectRequired: Number(row.reconnect_required) === 1,
261
+ actionsEnabled: Number(row.actions_enabled) === 1,
262
+ readTools: parseTools(row.read_tools_json),
263
+ actionTools: parseTools(row.action_tools_json),
264
+ toolRevision: typeof row.tool_revision === "string" ? row.tool_revision : null,
265
+ lastCheckedAt: typeof row.last_checked_at === "string" ? row.last_checked_at : null,
266
+ lastSuccessfulCallAt: typeof row.last_successful_call_at === "string" ? row.last_successful_call_at : null,
267
+ lastFailureAt: typeof row.last_failure_at === "string" ? row.last_failure_at : null,
268
+ lastFailureCode: typeof row.last_failure_code === "string" ? row.last_failure_code : null,
269
+ lastFailureRetryable: row.last_failure_retryable === null || row.last_failure_retryable === void 0 ? null : Number(row.last_failure_retryable) === 1,
270
+ consecutiveFailures: Number(row.consecutive_failures ?? 0),
271
+ vaultName: typeof row.vault_name === "string" ? row.vault_name : null,
272
+ tableName: typeof row.table_name === "string" ? row.table_name : null,
273
+ createdAt: String(row.created_at),
274
+ updatedAt: String(row.updated_at)
275
+ };
276
+ }
277
+ async function userIdForIdentity(identity) {
278
+ const user = await getUserByEmail(identity.normalize("NFKC").trim().toLowerCase());
279
+ if (!user) throw new Error("service_connection_user_not_found");
280
+ return Number(user.id);
281
+ }
282
+ async function reconcileDiscoveredNangoConnections(identity, discovered) {
283
+ await ensureServiceConnectionsSchema();
284
+ const db = getDb();
285
+ const userId = await userIdForIdentity(identity);
286
+ for (const connection of discovered) {
287
+ await db.execute({
288
+ sql: `
289
+ INSERT INTO service_connections (
290
+ id, user_id, provider_config_key, provider, transport, upstream_connection_id,
291
+ label, lifecycle_status, reconnect_required, actions_enabled, legacy_owner_identity, created_at, updated_at
292
+ ) VALUES (?, ?, ?, ?, 'nango', ?, ?, ?, ?, 1, ?, COALESCE(?, datetime('now')), COALESCE(?, datetime('now')))
293
+ ON CONFLICT(user_id, provider_config_key, upstream_connection_id) DO UPDATE SET
294
+ provider = excluded.provider,
295
+ label = COALESCE(excluded.label, service_connections.label),
296
+ lifecycle_status = excluded.lifecycle_status,
297
+ reconnect_required = excluded.reconnect_required,
298
+ updated_at = excluded.updated_at
299
+ `,
300
+ args: [
301
+ randomUUID(),
302
+ userId,
303
+ connection.providerConfigKey,
304
+ connection.provider,
305
+ connection.upstreamConnectionId,
306
+ connection.label,
307
+ connection.lifecycleStatus,
308
+ connection.reconnectRequired ? 1 : 0,
309
+ identity,
310
+ connection.createdAt,
311
+ connection.updatedAt
312
+ ]
313
+ });
314
+ }
315
+ return listServiceConnections(identity, "nango");
316
+ }
317
+ async function listServiceConnections(identity, transport) {
318
+ await ensureServiceConnectionsSchema();
319
+ const userId = await userIdForIdentity(identity);
320
+ const result = await getDb().execute({
321
+ sql: `SELECT * FROM service_connections WHERE user_id = ?${transport ? " AND transport = ?" : ""} AND lifecycle_status <> 'disconnected' ORDER BY updated_at DESC`,
322
+ args: transport ? [userId, transport] : [userId]
323
+ });
324
+ return result.rows.map((row) => rowToRecord(row));
325
+ }
326
+ async function getOwnedServiceConnection(identity, connectionId) {
327
+ await ensureServiceConnectionsSchema();
328
+ const userId = await userIdForIdentity(identity);
329
+ const result = await getDb().execute({
330
+ sql: "SELECT * FROM service_connections WHERE id = ? AND user_id = ? LIMIT 1",
331
+ args: [connectionId, userId]
332
+ });
333
+ const row = result.rows[0];
334
+ return row ? rowToRecord(row) : null;
335
+ }
336
+ async function updateServiceConnectionTools(connectionId, readTools, actionTools, toolRevision) {
337
+ await ensureServiceConnectionsSchema();
338
+ await getDb().execute({
339
+ sql: `UPDATE service_connections SET read_tools_json = ?, action_tools_json = ?, tool_revision = ?, updated_at = datetime('now') WHERE id = ?`,
340
+ args: [JSON.stringify(readTools), JSON.stringify(actionTools), toolRevision, connectionId]
341
+ });
342
+ }
343
+ async function setServiceConnectionProviderIdentity(args) {
344
+ await ensureServiceConnectionsSchema();
345
+ await getDb().execute({
346
+ sql: `
347
+ UPDATE service_connections SET
348
+ provider_account_id = ?, provider_account_email = ?, provider_account_name = ?,
349
+ provider_identity_checked_at = datetime('now'), provider_identity_source_updated_at = ?,
350
+ updated_at = datetime('now')
351
+ WHERE id = ?
352
+ `,
353
+ args: [
354
+ args.providerAccountId ?? null,
355
+ args.providerAccountEmail?.toLowerCase() ?? null,
356
+ args.providerAccountName ?? null,
357
+ args.sourceUpdatedAt,
358
+ args.connectionId
359
+ ]
360
+ });
361
+ }
362
+ async function setServiceConnectionUserLabel(identity, connectionId, userLabel) {
363
+ const connection = await getOwnedServiceConnection(identity, connectionId);
364
+ if (!connection || connection.lifecycleStatus === "disconnected") throw new Error("service_connection_not_found");
365
+ await getDb().execute({
366
+ sql: `UPDATE service_connections SET user_label = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?`,
367
+ args: [userLabel, connectionId, connection.userId]
368
+ });
369
+ return userLabel;
370
+ }
371
+ async function recordServiceConnectionHealth(args) {
372
+ await ensureServiceConnectionsSchema();
373
+ const now = (/* @__PURE__ */ new Date()).toISOString();
374
+ await getDb().execute({
375
+ sql: `
376
+ UPDATE service_connections SET
377
+ operational_status = ?, last_checked_at = ?,
378
+ last_successful_call_at = CASE WHEN ? = 'available' THEN ? ELSE last_successful_call_at END,
379
+ last_failure_at = CASE WHEN ? IN ('degraded', 'unavailable') THEN ? ELSE NULL END,
380
+ last_failure_code = ?, last_failure_retryable = ?,
381
+ consecutive_failures = CASE WHEN ? = 'available' THEN 0 ELSE consecutive_failures + 1 END,
382
+ updated_at = datetime('now')
383
+ WHERE id = ?
384
+ `,
385
+ args: [
386
+ args.operationalStatus,
387
+ now,
388
+ args.operationalStatus,
389
+ now,
390
+ args.operationalStatus,
391
+ now,
392
+ args.failureCode ?? null,
393
+ args.retryable == null ? null : args.retryable ? 1 : 0,
394
+ args.operationalStatus,
395
+ args.connectionId
396
+ ]
397
+ });
398
+ await getDb().execute({
399
+ sql: `INSERT INTO service_connection_health_events (id, connection_id, operational_status, failure_code, retryable, evidence_source) VALUES (?, ?, ?, ?, ?, ?)`,
400
+ args: [randomUUID(), args.connectionId, args.operationalStatus, args.failureCode ?? null, args.retryable == null ? null : args.retryable ? 1 : 0, args.evidenceSource]
401
+ });
402
+ }
403
+ async function setServiceConnectionActions(identity, connectionId, enabled) {
404
+ const connection = await getOwnedServiceConnection(identity, connectionId);
405
+ if (!connection || connection.lifecycleStatus === "disconnected") throw new Error("service_connection_not_found");
406
+ await getDb().execute({
407
+ sql: `UPDATE service_connections SET actions_enabled = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?`,
408
+ args: [enabled ? 1 : 0, connectionId, connection.userId]
409
+ });
410
+ return enabled;
411
+ }
412
+ async function markServiceConnectionDisconnected(identity, connectionId) {
413
+ const connection = await getOwnedServiceConnection(identity, connectionId);
414
+ if (!connection) throw new Error("service_connection_not_found");
415
+ await getDb().execute({
416
+ sql: `UPDATE service_connections SET lifecycle_status = 'disconnected', operational_status = 'unknown', reconnect_required = 0, updated_at = datetime('now') WHERE id = ? AND user_id = ?`,
417
+ args: [connectionId, connection.userId]
418
+ });
419
+ const remaining = await getDb().execute({
420
+ sql: `SELECT COUNT(*) AS count FROM service_connections WHERE user_id = ? AND transport = 'nango' AND lifecycle_status = 'connected'`,
421
+ args: [connection.userId]
422
+ });
423
+ return Number(remaining.rows[0]?.count ?? 0);
424
+ }
425
+ async function recordServiceConnectionAction(args) {
426
+ await ensureServiceConnectionsSchema();
427
+ const connection = await getOwnedServiceConnection(args.identity, args.connectionId);
428
+ if (!connection) throw new Error("service_connection_not_found");
429
+ await getDb().execute({
430
+ sql: `UPDATE service_connection_action_audit SET status = ?, error_code = ?, result_json = ? WHERE user_id = ? AND request_id = ? AND connection_id = ?`,
431
+ args: [args.status, args.errorCode ?? null, args.result === void 0 ? null : JSON.stringify(args.result), connection.userId, args.requestId, connection.id]
432
+ });
433
+ }
434
+ async function claimServiceConnectionAction(args) {
435
+ await ensureServiceConnectionsSchema();
436
+ const connection = await getOwnedServiceConnection(args.identity, args.connectionId);
437
+ if (!connection) throw new Error("service_connection_not_found");
438
+ const inserted = await getDb().execute({
439
+ sql: `INSERT OR IGNORE INTO service_connection_action_audit (id, connection_id, user_id, tool, request_id, status, request_digest) VALUES (?, ?, ?, ?, ?, 'started', ?)`,
440
+ args: [randomUUID(), connection.id, connection.userId, args.tool, args.requestId, args.requestDigest]
441
+ });
442
+ if (Number(inserted.rowsAffected ?? 0) === 1) return { claimed: true };
443
+ const existing = await getDb().execute({
444
+ sql: `SELECT connection_id, tool, status, error_code, request_digest, result_json FROM service_connection_action_audit WHERE user_id = ? AND request_id = ? LIMIT 1`,
445
+ args: [connection.userId, args.requestId]
446
+ });
447
+ const row = existing.rows[0];
448
+ const conflict = String(row?.connection_id ?? "") !== connection.id || String(row?.tool ?? "") !== args.tool || String(row?.request_digest ?? "") !== args.requestDigest;
449
+ let result = void 0;
450
+ if (!conflict && typeof row?.result_json === "string") {
451
+ try {
452
+ result = JSON.parse(row.result_json);
453
+ } catch {
454
+ result = void 0;
455
+ }
456
+ }
457
+ return {
458
+ claimed: false,
459
+ status: String(row?.status ?? "unknown"),
460
+ errorCode: typeof row?.error_code === "string" ? row.error_code : null,
461
+ conflict,
462
+ ...result !== void 0 ? { result } : {}
463
+ };
464
+ }
465
+
466
+ // src/api/analytics-provider-registry.ts
467
+ var META_ACTION_SOURCES = /* @__PURE__ */ new Set([
468
+ "email",
469
+ "website",
470
+ "app",
471
+ "phone_call",
472
+ "chat",
473
+ "physical_store",
474
+ "system_generated",
475
+ "business_messaging",
476
+ "other"
477
+ ]);
478
+ function recordValue(value) {
479
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
480
+ }
481
+ function boundedString(value, max = 2048) {
482
+ return typeof value === "string" && value.trim() ? value.trim().slice(0, max) : void 0;
483
+ }
484
+ function metaEventTime(value) {
485
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
486
+ return Math.trunc(value > 1e10 ? value / 1e3 : value);
487
+ }
488
+ if (typeof value === "string") {
489
+ const milliseconds = Date.parse(value);
490
+ if (Number.isFinite(milliseconds) && milliseconds > 0) {
491
+ return Math.trunc(milliseconds / 1e3);
492
+ }
493
+ }
494
+ return Math.trunc(Date.now() / 1e3);
495
+ }
496
+ function googleEventTime(value) {
497
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
498
+ return new Date(value < 1e10 ? value * 1e3 : value).toISOString();
499
+ }
500
+ if (typeof value === "string") {
501
+ const milliseconds = Date.parse(value);
502
+ if (Number.isFinite(milliseconds) && milliseconds > 0) return new Date(milliseconds).toISOString();
503
+ }
504
+ throw new AnalyticsProviderRegistryError(
505
+ "analytics_provider_destination_required",
506
+ "Google Data Manager requires a valid eventTimestamp."
507
+ );
508
+ }
509
+ function eventTimeMilliseconds(value, provider) {
510
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
511
+ return Math.trunc(value < 1e10 ? value * 1e3 : value);
512
+ }
513
+ if (typeof value === "string") {
514
+ const milliseconds = Date.parse(value);
515
+ if (Number.isFinite(milliseconds) && milliseconds > 0) return Math.trunc(milliseconds);
516
+ }
517
+ throw new AnalyticsProviderRegistryError(
518
+ "analytics_provider_destination_required",
519
+ `${provider} requires a valid event timestamp.`
520
+ );
521
+ }
522
+ function sha256Value(value) {
523
+ const candidate = boundedString(value, 64)?.toLowerCase();
524
+ return candidate && /^[a-f0-9]{64}$/.test(candidate) ? candidate : void 0;
525
+ }
526
+ function googleConsent(payload) {
527
+ const consent = recordValue(payload.consent);
528
+ const adUserData = consent.adUserData === "granted" ? "CONSENT_GRANTED" : consent.adUserData === "denied" ? "CONSENT_DENIED" : void 0;
529
+ const adPersonalization = consent.adPersonalization === "granted" ? "CONSENT_GRANTED" : consent.adPersonalization === "denied" ? "CONSENT_DENIED" : void 0;
530
+ return adUserData ? { adUserData, ...adPersonalization ? { adPersonalization } : {} } : void 0;
531
+ }
532
+ function buildGoogleDeliveryRequest(destinationId, payload) {
533
+ const operatingAccountId = boundedString(
534
+ payload.operatingAccountId ?? payload.googleAdsCustomerId ?? payload.customerId,
535
+ 256
536
+ );
537
+ if (!operatingAccountId) {
538
+ throw new AnalyticsProviderRegistryError(
539
+ "analytics_provider_destination_required",
540
+ "A Google Ads operating account ID is required for Data Manager activation."
541
+ );
542
+ }
543
+ const transactionId = boundedString(
544
+ payload.transactionId ?? payload.orderId ?? payload.eventId,
545
+ 256
546
+ );
547
+ if (!transactionId) {
548
+ throw new AnalyticsProviderRegistryError(
549
+ "analytics_provider_destination_required",
550
+ "A stable transactionId, orderId, or eventId is required for Google deduplication."
551
+ );
552
+ }
553
+ const clickIds = recordValue(payload.clickIds);
554
+ const match = recordValue(payload.match);
555
+ const gclid = boundedString(clickIds.gclid, 2e3);
556
+ const gbraid = boundedString(clickIds.gbraid, 2e3);
557
+ const wbraid = boundedString(clickIds.wbraid, 2e3);
558
+ const emailSha256 = boundedString(match.emailSha256, 64);
559
+ const phoneSha256 = boundedString(match.phoneSha256, 64);
560
+ if (!gclid && !gbraid && !wbraid && !emailSha256 && !phoneSha256) {
561
+ throw new AnalyticsProviderRegistryError(
562
+ "analytics_provider_destination_required",
563
+ "Google Data Manager requires a click identifier or normalized SHA-256 user identifier."
564
+ );
565
+ }
566
+ const valueMinor = typeof payload.valueMinor === "number" && Number.isSafeInteger(payload.valueMinor) && payload.valueMinor >= 0 ? payload.valueMinor : void 0;
567
+ const currency = boundedString(payload.currency, 3)?.toUpperCase();
568
+ const actionSource = boundedString(payload.actionSource, 64);
569
+ const consent = googleConsent(payload);
570
+ return {
571
+ operatingAccountId,
572
+ ...boundedString(payload.loginAccountId, 256) ? { loginAccountId: boundedString(payload.loginAccountId, 256) } : {},
573
+ conversionActionId: destinationId,
574
+ events: [{
575
+ eventTimestamp: googleEventTime(payload.eventTime ?? payload.eventTimestamp),
576
+ transactionId,
577
+ eventSource: actionSource === "phone_call" ? "PHONE" : actionSource === "app" ? "APP" : "WEB",
578
+ ...gclid ? { gclid } : {},
579
+ ...gbraid ? { gbraid } : {},
580
+ ...wbraid ? { wbraid } : {},
581
+ ...emailSha256 ? { emailSha256: [emailSha256] } : {},
582
+ ...phoneSha256 ? { phoneSha256: [phoneSha256] } : {},
583
+ ...valueMinor !== void 0 && currency ? { conversionValue: valueMinor / 100, currency } : {},
584
+ ...consent ? { consent } : {}
585
+ }],
586
+ ...consent ? { consent } : {},
587
+ validateOnly: false
588
+ };
589
+ }
590
+ function metaNetworkRetentionPermits(payload) {
591
+ const consent = recordValue(payload.consent);
592
+ const retentionUntil = boundedString(payload.networkDataRetentionUntil, 64);
593
+ const retentionExpiry = retentionUntil ? Date.parse(retentionUntil) : Number.NaN;
594
+ return consent.analytics === "granted" && consent.adUserData === "granted" && consent.globalPrivacyControl !== true && Number.isFinite(retentionExpiry) && retentionExpiry > Date.now();
595
+ }
596
+ function metaUserData(payload) {
597
+ const match = recordValue(payload.match);
598
+ const clickIds = recordValue(payload.clickIds);
599
+ const emailSha256 = boundedString(match.emailSha256, 64);
600
+ const phoneSha256 = boundedString(match.phoneSha256, 64);
601
+ const fbp = boundedString(clickIds.fbp, 255);
602
+ const fbc = boundedString(clickIds.fbc, 255);
603
+ const userData = {
604
+ ...emailSha256 ? { em: [emailSha256] } : {},
605
+ ...phoneSha256 ? { ph: [phoneSha256] } : {},
606
+ ...fbp ? { fbp } : {},
607
+ ...fbc ? { fbc } : {}
608
+ };
609
+ if (metaNetworkRetentionPermits(payload)) {
610
+ const clientIpAddress = boundedString(match.clientIpAddress, 64);
611
+ const clientUserAgent = boundedString(match.clientUserAgent, 1e3);
612
+ if (clientIpAddress) userData.client_ip_address = clientIpAddress;
613
+ if (clientUserAgent) userData.client_user_agent = clientUserAgent;
614
+ }
615
+ return userData;
616
+ }
617
+ function buildMetaDeliveryRequest(destinationId, eventName, payload) {
618
+ const actionSource = boundedString(payload.actionSource, 64);
619
+ const valueMinor = typeof payload.valueMinor === "number" && Number.isSafeInteger(payload.valueMinor) ? payload.valueMinor : void 0;
620
+ const currency = boundedString(payload.currency, 3)?.toUpperCase();
621
+ const orderId = boundedString(payload.orderId, 240);
622
+ const customData = {
623
+ ...valueMinor !== void 0 && valueMinor >= 0 ? { value: valueMinor / 100 } : {},
624
+ ...currency ? { currency } : {},
625
+ ...orderId ? { order_id: orderId } : {}
626
+ };
627
+ const eventId = boundedString(payload.eventId, 255);
628
+ const eventSourceUrl = boundedString(payload.sourceUrl);
629
+ const event = {
630
+ eventName,
631
+ eventTime: metaEventTime(payload.eventTime),
632
+ actionSource: actionSource && META_ACTION_SOURCES.has(actionSource) ? actionSource : "system_generated",
633
+ ...eventId ? { eventId } : {},
634
+ ...eventSourceUrl ? { eventSourceUrl } : {},
635
+ userData: metaUserData(payload),
636
+ ...Object.keys(customData).length ? { customData } : {}
637
+ };
638
+ const testEventCode = boundedString(payload.testEventCode, 64);
639
+ return {
640
+ datasetId: destinationId,
641
+ events: [event],
642
+ ...testEventCode ? { testEventCode } : {}
643
+ };
644
+ }
645
+ function privacyPermitsNetworkData(payload) {
646
+ return metaNetworkRetentionPermits(payload);
647
+ }
648
+ function buildTikTokDeliveryRequest(destinationId, eventName, payload) {
649
+ const eventId = boundedString(payload.eventId, 255);
650
+ if (!eventId) {
651
+ throw new AnalyticsProviderRegistryError(
652
+ "analytics_provider_destination_required",
653
+ "TikTok requires the stable X-Ray conversion ID for event deduplication."
654
+ );
655
+ }
656
+ const clickIds = recordValue(payload.clickIds);
657
+ const match = recordValue(payload.match);
658
+ const emailSha256 = sha256Value(match.emailSha256);
659
+ const phoneSha256 = sha256Value(match.phoneSha256);
660
+ const externalIdSha256 = sha256Value(match.externalIdSha256);
661
+ const ttclid = boundedString(clickIds.ttclid, 2e3);
662
+ const ttp = boundedString(clickIds.ttp, 255);
663
+ const userData = {
664
+ ...emailSha256 ? { emailSha256: [emailSha256] } : {},
665
+ ...phoneSha256 ? { phoneSha256: [phoneSha256] } : {},
666
+ ...externalIdSha256 ? { externalIdSha256: [externalIdSha256] } : {},
667
+ ...ttclid ? { ttclid } : {},
668
+ ...ttp ? { ttp } : {}
669
+ };
670
+ if (privacyPermitsNetworkData(payload)) {
671
+ const clientIpAddress = boundedString(match.clientIpAddress, 64);
672
+ const clientUserAgent = boundedString(match.clientUserAgent, 1e3);
673
+ if (clientIpAddress) userData.clientIpAddress = clientIpAddress;
674
+ if (clientUserAgent) userData.clientUserAgent = clientUserAgent;
675
+ }
676
+ if (!Object.keys(userData).length) {
677
+ throw new AnalyticsProviderRegistryError(
678
+ "analytics_provider_destination_required",
679
+ "TikTok requires ttclid, ttp, or a documented SHA-256 match field."
680
+ );
681
+ }
682
+ const eventTime = Math.trunc(eventTimeMilliseconds(payload.eventTime, "TikTok") / 1e3);
683
+ const valueMinor = typeof payload.valueMinor === "number" && Number.isSafeInteger(payload.valueMinor) && payload.valueMinor >= 0 ? payload.valueMinor : void 0;
684
+ const currency = boundedString(payload.currency, 3)?.toUpperCase();
685
+ const sourceUrl = boundedString(payload.sourceUrl, 2048);
686
+ const referrer = boundedString(payload.referrer, 2048);
687
+ const orderId = boundedString(payload.orderId, 255);
688
+ const testEventCode = boundedString(payload.testEventCode, 128);
689
+ return {
690
+ pixelCode: destinationId,
691
+ events: [{
692
+ eventId,
693
+ eventName,
694
+ eventTime,
695
+ userData,
696
+ ...sourceUrl ? { page: { url: sourceUrl, ...referrer ? { referrer } : {} } } : {},
697
+ ...valueMinor !== void 0 && currency ? { properties: { value: valueMinor / 100, currency, ...orderId ? { orderId } : {} } } : orderId ? { properties: { orderId } } : {}
698
+ }],
699
+ ...testEventCode ? { testEventCode } : {},
700
+ schemaValidationOnly: false
701
+ };
702
+ }
703
+ var REDDIT_TRACKING_TYPES = /* @__PURE__ */ new Map([
704
+ ["page_view", "PAGE_VISIT"],
705
+ ["page_visit", "PAGE_VISIT"],
706
+ ["view_content", "VIEW_CONTENT"],
707
+ ["search", "SEARCH"],
708
+ ["add_to_cart", "ADD_TO_CART"],
709
+ ["add_to_wishlist", "ADD_TO_WISHLIST"],
710
+ ["purchase", "PURCHASE"],
711
+ ["lead", "LEAD"],
712
+ ["qualified_lead", "LEAD"],
713
+ ["sign_up", "SIGN_UP"],
714
+ ["signup", "SIGN_UP"]
715
+ ]);
716
+ function redditActionSource(value) {
717
+ const source = boundedString(value, 64);
718
+ if (source === "website") return "WEBSITE";
719
+ if (source === "app") return "APP";
720
+ if (source === "physical_store") return "PHYSICAL_STORE";
721
+ return "OTHER";
722
+ }
723
+ function buildRedditDeliveryRequest(destinationId, eventName, payload) {
724
+ const conversionId = boundedString(payload.eventId, 255);
725
+ if (!conversionId) {
726
+ throw new AnalyticsProviderRegistryError(
727
+ "analytics_provider_destination_required",
728
+ "Reddit requires the stable X-Ray conversion ID for event deduplication."
729
+ );
730
+ }
731
+ const clickIds = recordValue(payload.clickIds);
732
+ const match = recordValue(payload.match);
733
+ const clickId = boundedString(clickIds.rdtCid ?? clickIds.rdt_cid, 2e3);
734
+ const emailSha256 = sha256Value(match.emailSha256);
735
+ const phoneSha256 = sha256Value(match.phoneSha256);
736
+ const externalIdSha256 = sha256Value(match.externalIdSha256);
737
+ const uuid = boundedString(match.redditUuid ?? match.uuid, 255);
738
+ const user = {
739
+ ...emailSha256 ? { emailSha256 } : {},
740
+ ...phoneSha256 ? { phoneSha256 } : {},
741
+ ...externalIdSha256 ? { externalIdSha256 } : {},
742
+ ...uuid ? { uuid } : {}
743
+ };
744
+ if (privacyPermitsNetworkData(payload)) {
745
+ const ipAddress = boundedString(match.clientIpAddress, 64);
746
+ const userAgent = boundedString(match.clientUserAgent, 1e3);
747
+ if (ipAddress) user.ipAddress = ipAddress;
748
+ if (userAgent) user.userAgent = userAgent;
749
+ }
750
+ if (!clickId && !Object.keys(user).length) {
751
+ throw new AnalyticsProviderRegistryError(
752
+ "analytics_provider_destination_required",
753
+ "Reddit requires rdt_cid or a documented user match field."
754
+ );
755
+ }
756
+ const normalizedName = eventName.trim().toLowerCase().replace(/[ -]+/g, "_");
757
+ const trackingType = REDDIT_TRACKING_TYPES.get(normalizedName) ?? "CUSTOM";
758
+ const valueMinor = typeof payload.valueMinor === "number" && Number.isSafeInteger(payload.valueMinor) && payload.valueMinor >= 0 ? payload.valueMinor : void 0;
759
+ const currency = boundedString(payload.currency, 3)?.toUpperCase();
760
+ const itemCount = typeof payload.itemCount === "number" && Number.isSafeInteger(payload.itemCount) && payload.itemCount >= 0 ? payload.itemCount : void 0;
761
+ const sourceUrl = boundedString(payload.sourceUrl, 2048);
762
+ const testId = boundedString(payload.testId, 255);
763
+ return {
764
+ pixelId: destinationId,
765
+ events: [{
766
+ conversionId,
767
+ ...clickId ? { clickId } : {},
768
+ eventAt: eventTimeMilliseconds(payload.eventTime, "Reddit"),
769
+ actionSource: redditActionSource(payload.actionSource),
770
+ ...sourceUrl ? { eventSourceUrl: sourceUrl } : {},
771
+ trackingType,
772
+ ...trackingType === "CUSTOM" ? { customEventName: eventName.slice(0, 64) } : {},
773
+ ...valueMinor !== void 0 && currency ? { value: valueMinor / 100, currency } : {},
774
+ ...itemCount !== void 0 ? { itemCount } : {},
775
+ user
776
+ }],
777
+ ...testId ? { testId } : {},
778
+ schemaValidationOnly: false
779
+ };
780
+ }
781
+ var AnalyticsProviderRegistryError = class extends Error {
782
+ code;
783
+ constructor(code, message) {
784
+ super(message);
785
+ this.name = "AnalyticsProviderRegistryError";
786
+ this.code = code;
787
+ }
788
+ };
789
+ var DEFAULT_RETRY_CLASSIFICATION = Object.freeze({
790
+ retryableStatusCodes: Object.freeze([408, 425, 429, 500, 502, 503, 504]),
791
+ permanentStatusCodes: Object.freeze([400, 401, 403, 404, 409, 422]),
792
+ maxAttempts: 8
793
+ });
794
+ var ANALYTICS_PROVIDER_REGISTRY = Object.freeze({
795
+ meta: Object.freeze({
796
+ platform: "meta",
797
+ integrationKeys: Object.freeze(["meta-marketing-api"]),
798
+ actionName: "send-conversion-events",
799
+ requiredDestinationField: "datasetId",
800
+ supportsTestMode: true,
801
+ supportsDiagnostics: false,
802
+ maxBatchSize: 1e3,
803
+ retryClassification: DEFAULT_RETRY_CLASSIFICATION
804
+ }),
805
+ google: Object.freeze({
806
+ platform: "google",
807
+ integrationKeys: Object.freeze(["google-data-manager"]),
808
+ actionName: "send-conversion-events",
809
+ requiredDestinationField: "conversionActionId",
810
+ supportsTestMode: true,
811
+ supportsDiagnostics: true,
812
+ diagnosticsActionName: "get-ingestion-diagnostics",
813
+ maxBatchSize: 1e3,
814
+ retryClassification: DEFAULT_RETRY_CLASSIFICATION
815
+ }),
816
+ tiktok: Object.freeze({
817
+ platform: "tiktok",
818
+ integrationKeys: Object.freeze(["tiktok-business"]),
819
+ actionName: "send-events",
820
+ requiredDestinationField: "pixelCode",
821
+ supportsTestMode: true,
822
+ supportsDiagnostics: false,
823
+ maxBatchSize: 1e3,
824
+ retryClassification: DEFAULT_RETRY_CLASSIFICATION
825
+ }),
826
+ reddit: Object.freeze({
827
+ platform: "reddit",
828
+ integrationKeys: Object.freeze(["reddit-ads"]),
829
+ actionName: "send-conversion-events",
830
+ requiredDestinationField: "pixelId",
831
+ supportsTestMode: true,
832
+ supportsDiagnostics: false,
833
+ maxBatchSize: 1e3,
834
+ retryClassification: DEFAULT_RETRY_CLASSIFICATION
835
+ })
836
+ });
837
+ function getAnalyticsProviderDefinition(platform) {
838
+ return ANALYTICS_PROVIDER_REGISTRY[platform];
839
+ }
840
+ function assertProviderDestination(platform, destinationId) {
841
+ const trimmed = destinationId?.trim() ?? "";
842
+ if (!trimmed) {
843
+ const definition = getAnalyticsProviderDefinition(platform);
844
+ throw new AnalyticsProviderRegistryError(
845
+ "analytics_provider_destination_required",
846
+ `A ${definition.requiredDestinationField} is required for this activation provider.`
847
+ );
848
+ }
849
+ return trimmed;
850
+ }
851
+ function assertSafeAnalyticsEventMapping(eventMapping) {
852
+ if (eventMapping && Object.prototype.hasOwnProperty.call(eventMapping, "__tool")) {
853
+ throw new AnalyticsProviderRegistryError(
854
+ "analytics_provider_action_unavailable",
855
+ "Activation action selection is managed by X-Ray."
856
+ );
857
+ }
858
+ }
859
+ function assertConnectionSupportsProvider(connection, platform) {
860
+ const definition = getAnalyticsProviderDefinition(platform);
861
+ if (!serviceConnectionAdvertisesAction(
862
+ connection,
863
+ definition.integrationKeys,
864
+ definition.actionName
865
+ )) {
866
+ throw new AnalyticsProviderRegistryError(
867
+ "analytics_provider_action_unavailable",
868
+ "The selected connection cannot send conversions for this provider."
869
+ );
870
+ }
871
+ return definition;
872
+ }
873
+ function mappedEventName(input) {
874
+ const canonical = String(input.payload.eventName || "conversion");
875
+ return input.eventMapping?.[canonical] || canonical;
876
+ }
877
+ function buildProviderDeliveryRequest(input) {
878
+ const destinationId = assertProviderDestination(input.platform, input.destinationId);
879
+ assertSafeAnalyticsEventMapping(input.eventMapping);
880
+ const eventName = mappedEventName(input);
881
+ if (input.platform === "meta") {
882
+ return buildMetaDeliveryRequest(destinationId, eventName, input.payload);
883
+ }
884
+ if (input.platform === "google") {
885
+ return buildGoogleDeliveryRequest(destinationId, input.payload);
886
+ }
887
+ if (input.platform === "tiktok") {
888
+ return buildTikTokDeliveryRequest(destinationId, eventName, input.payload);
889
+ }
890
+ return buildRedditDeliveryRequest(destinationId, eventName, input.payload);
891
+ }
892
+ function buildProviderTestRequest(input) {
893
+ const definition = getAnalyticsProviderDefinition(input.platform);
894
+ const request = buildProviderDeliveryRequest(input);
895
+ if (input.platform === "google") return { ...request, validateOnly: true };
896
+ if (input.platform === "tiktok") {
897
+ return boundedString(input.payload.testEventCode, 128) ? request : { ...request, schemaValidationOnly: true };
898
+ }
899
+ if (input.platform === "reddit") {
900
+ return boundedString(input.payload.testId, 255) ? request : { ...request, schemaValidationOnly: true };
901
+ }
902
+ return definition.supportsTestMode ? { ...request, testMode: true } : request;
903
+ }
904
+ function optionalString(record, ...keys) {
905
+ for (const key of keys) {
906
+ const value = record[key];
907
+ if (typeof value === "string" && value.trim()) return value.trim().slice(0, 500);
908
+ }
909
+ return void 0;
910
+ }
911
+ function normalizeProviderReceipt(platform, receipt, stableEventId) {
912
+ void getAnalyticsProviderDefinition(platform);
913
+ const record = receipt && typeof receipt === "object" ? receipt : {};
914
+ const warnings = [record.warnings, record.messages, record.fieldWarnings].flatMap((value) => Array.isArray(value) ? value : []).map((value) => {
915
+ if (typeof value === "string") return value;
916
+ const warning = recordValue(value);
917
+ const reason = boundedString(warning.reason, 200);
918
+ const field = boundedString(warning.field, 200);
919
+ const description = boundedString(warning.description, 500);
920
+ return [reason, field, description].filter(Boolean).join(": ");
921
+ }).filter((value) => Boolean(value)).slice(0, 50).map((value) => value.slice(0, 500));
922
+ const eventsReceived = typeof record.eventsReceived === "number" && Number.isFinite(record.eventsReceived) ? Math.max(0, Math.trunc(record.eventsReceived)) : void 0;
923
+ const eventsRejected = typeof record.eventsRejected === "number" && Number.isFinite(record.eventsRejected) ? Math.max(0, Math.trunc(record.eventsRejected)) : void 0;
924
+ const partiallyAccepted = record.partiallyAccepted === true;
925
+ return {
926
+ accepted: record.schemaValidationOnly !== true && (record.accepted === true || record.success === true || record.providerAccepted === true || record.acceptedForProcessing === true || (eventsReceived ?? 0) > 0),
927
+ ...partiallyAccepted ? { partiallyAccepted: true } : {},
928
+ ...optionalString(record, "requestId", "receiptId", "id") ? { requestId: optionalString(record, "requestId", "receiptId", "id") } : {},
929
+ ...optionalString(record, "eventId", "conversionId") || boundedString(stableEventId, 255) ? { eventId: optionalString(record, "eventId", "conversionId") || boundedString(stableEventId, 255) } : {},
930
+ ...eventsReceived !== void 0 ? { eventsReceived } : {},
931
+ ...eventsRejected !== void 0 ? { eventsRejected } : {},
932
+ warnings
933
+ };
934
+ }
935
+
114
936
  // src/api/analytics-repository.ts
115
937
  var AnalyticsRepositoryError = class extends Error {
116
938
  constructor(code, message, status = 400) {
@@ -205,6 +1027,26 @@ async function migrateAnalytics() {
205
1027
  UNIQUE(pixel_id, hostname)
206
1028
  )
207
1029
  `);
1030
+ await client.query(`
1031
+ CREATE TABLE IF NOT EXISTS analytics_host_groups (
1032
+ id uuid PRIMARY KEY,
1033
+ site_id uuid NOT NULL REFERENCES analytics_sites(id) ON DELETE CASCADE,
1034
+ pixel_id uuid NOT NULL REFERENCES analytics_pixels(id) ON DELETE CASCADE,
1035
+ name text NOT NULL,
1036
+ auto_decorate boolean NOT NULL DEFAULT false,
1037
+ created_at timestamptz NOT NULL DEFAULT now(),
1038
+ updated_at timestamptz NOT NULL DEFAULT now(),
1039
+ UNIQUE(pixel_id, name)
1040
+ )
1041
+ `);
1042
+ await client.query(`
1043
+ CREATE TABLE IF NOT EXISTS analytics_host_group_members (
1044
+ group_id uuid NOT NULL REFERENCES analytics_host_groups(id) ON DELETE CASCADE,
1045
+ hostname text NOT NULL,
1046
+ created_at timestamptz NOT NULL DEFAULT now(),
1047
+ PRIMARY KEY(group_id, hostname)
1048
+ )
1049
+ `);
208
1050
  await client.query(`
209
1051
  CREATE TABLE IF NOT EXISTS analytics_events (
210
1052
  id uuid PRIMARY KEY,
@@ -420,6 +1262,20 @@ async function migrateAnalytics() {
420
1262
  UNIQUE(person_id, identity_node_id, evidence_kind)
421
1263
  )
422
1264
  `);
1265
+ await client.query(`
1266
+ CREATE TABLE IF NOT EXISTS analytics_linker_tokens (
1267
+ id uuid PRIMARY KEY,
1268
+ site_id uuid NOT NULL REFERENCES analytics_sites(id) ON DELETE CASCADE,
1269
+ pixel_id uuid NOT NULL REFERENCES analytics_pixels(id) ON DELETE CASCADE,
1270
+ host_group_id uuid NOT NULL REFERENCES analytics_host_groups(id) ON DELETE CASCADE,
1271
+ identity_node_id uuid NOT NULL REFERENCES analytics_identity_nodes(id) ON DELETE CASCADE,
1272
+ source_hostname text NOT NULL,
1273
+ audience_hostname text NOT NULL,
1274
+ expires_at timestamptz NOT NULL,
1275
+ consumed_at timestamptz,
1276
+ created_at timestamptz NOT NULL DEFAULT now()
1277
+ )
1278
+ `);
423
1279
  await client.query(
424
1280
  `ALTER TABLE analytics_events ADD COLUMN IF NOT EXISTS click_ids jsonb NOT NULL DEFAULT '{}'::jsonb`
425
1281
  );
@@ -596,6 +1452,302 @@ async function migrateAnalytics() {
596
1452
  await client.query(
597
1453
  `ALTER TABLE analytics_events ADD COLUMN IF NOT EXISTS region_code text`
598
1454
  );
1455
+ await client.query(`
1456
+ CREATE TABLE IF NOT EXISTS analytics_sessions (
1457
+ id uuid PRIMARY KEY,
1458
+ site_id uuid NOT NULL REFERENCES analytics_sites(id) ON DELETE CASCADE,
1459
+ pixel_id uuid REFERENCES analytics_pixels(id) ON DELETE SET NULL,
1460
+ visitor_hmac text NOT NULL,
1461
+ session_hmac text NOT NULL,
1462
+ person_id uuid REFERENCES analytics_people(id) ON DELETE SET NULL,
1463
+ started_at timestamptz NOT NULL,
1464
+ last_active_at timestamptz NOT NULL,
1465
+ ended_at timestamptz,
1466
+ duration_ms bigint NOT NULL DEFAULT 0 CHECK (duration_ms >= 0),
1467
+ landing_path text,
1468
+ exit_path text,
1469
+ pageview_count integer NOT NULL DEFAULT 0,
1470
+ event_count integer NOT NULL DEFAULT 0,
1471
+ first_touch_id uuid,
1472
+ last_touch_id uuid,
1473
+ created_at timestamptz NOT NULL DEFAULT now(),
1474
+ updated_at timestamptz NOT NULL DEFAULT now(),
1475
+ UNIQUE(site_id, session_hmac)
1476
+ )
1477
+ `);
1478
+ await client.query(`
1479
+ CREATE TABLE IF NOT EXISTS analytics_attribution_touches (
1480
+ id uuid PRIMARY KEY,
1481
+ site_id uuid NOT NULL REFERENCES analytics_sites(id) ON DELETE CASCADE,
1482
+ session_id uuid REFERENCES analytics_sessions(id) ON DELETE SET NULL,
1483
+ person_id uuid REFERENCES analytics_people(id) ON DELETE SET NULL,
1484
+ visitor_hmac text,
1485
+ occurred_at timestamptz NOT NULL,
1486
+ source text,
1487
+ medium text,
1488
+ campaign text,
1489
+ referrer text,
1490
+ click_ids jsonb NOT NULL DEFAULT '{}'::jsonb,
1491
+ landing_path text,
1492
+ touch_hash text NOT NULL,
1493
+ created_at timestamptz NOT NULL DEFAULT now(),
1494
+ UNIQUE(site_id, touch_hash)
1495
+ )
1496
+ `);
1497
+ await client.query(`
1498
+ CREATE TABLE IF NOT EXISTS analytics_connections (
1499
+ id uuid PRIMARY KEY,
1500
+ site_id uuid NOT NULL REFERENCES analytics_sites(id) ON DELETE CASCADE,
1501
+ provider text NOT NULL,
1502
+ name text NOT NULL,
1503
+ source_account_ref text NOT NULL,
1504
+ service_connection_ref text,
1505
+ secret_ciphertext text,
1506
+ config jsonb NOT NULL DEFAULT '{}'::jsonb,
1507
+ readiness text NOT NULL DEFAULT 'configured_unverified'
1508
+ CHECK (readiness IN ('not_configured','configured_unverified','verified','live','degraded','disabled')),
1509
+ last_verified_at timestamptz,
1510
+ last_event_at timestamptz,
1511
+ last_reconciled_at timestamptz,
1512
+ last_error_code text,
1513
+ verification_evidence text CHECK (verification_evidence IN ('synthetic','provider')),
1514
+ last_verified_receipt_id uuid,
1515
+ created_by_user_id bigint NOT NULL,
1516
+ created_at timestamptz NOT NULL DEFAULT now(),
1517
+ updated_at timestamptz NOT NULL DEFAULT now(),
1518
+ UNIQUE(site_id, provider, source_account_ref)
1519
+ )
1520
+ `);
1521
+ await client.query(`
1522
+ CREATE TABLE IF NOT EXISTS analytics_webhook_receipts (
1523
+ id uuid PRIMARY KEY,
1524
+ site_id uuid NOT NULL REFERENCES analytics_sites(id) ON DELETE CASCADE,
1525
+ connection_id uuid REFERENCES analytics_connections(id) ON DELETE SET NULL,
1526
+ provider text NOT NULL,
1527
+ source_account_ref text NOT NULL,
1528
+ source_event_id text NOT NULL,
1529
+ signature_status text NOT NULL CHECK (signature_status IN ('verified','invalid','not_supported')),
1530
+ normalization_status text NOT NULL DEFAULT 'pending'
1531
+ CHECK (normalization_status IN ('pending','accepted','rejected','replayed')),
1532
+ payload_ciphertext text,
1533
+ payload_expires_at timestamptz,
1534
+ error_code text,
1535
+ received_at timestamptz NOT NULL DEFAULT now(),
1536
+ normalized_at timestamptz,
1537
+ UNIQUE(site_id, provider, source_account_ref, source_event_id)
1538
+ )
1539
+ `);
1540
+ await client.query(`
1541
+ CREATE TABLE IF NOT EXISTS analytics_external_events (
1542
+ id uuid PRIMARY KEY,
1543
+ site_id uuid NOT NULL REFERENCES analytics_sites(id) ON DELETE CASCADE,
1544
+ receipt_id uuid REFERENCES analytics_webhook_receipts(id) ON DELETE SET NULL,
1545
+ person_id uuid REFERENCES analytics_people(id) ON DELETE SET NULL,
1546
+ source text NOT NULL,
1547
+ source_account_ref text NOT NULL,
1548
+ source_event_id text NOT NULL,
1549
+ event_kind text NOT NULL CHECK (event_kind IN ('form','call','crm','transaction','custom_server','conversion','delivery')),
1550
+ event_name text NOT NULL,
1551
+ occurred_at timestamptz NOT NULL,
1552
+ visitor_hmac text,
1553
+ session_hmac text,
1554
+ call_id text,
1555
+ deal_id text,
1556
+ order_id text,
1557
+ value_minor bigint,
1558
+ currency text,
1559
+ click_ids jsonb NOT NULL DEFAULT '{}'::jsonb,
1560
+ properties jsonb NOT NULL DEFAULT '{}'::jsonb,
1561
+ created_at timestamptz NOT NULL DEFAULT now(),
1562
+ UNIQUE(site_id, source, source_account_ref, source_event_id)
1563
+ )
1564
+ `);
1565
+ await client.query(`
1566
+ CREATE TABLE IF NOT EXISTS analytics_reconciliation_cursors (
1567
+ connection_id uuid PRIMARY KEY REFERENCES analytics_connections(id) ON DELETE CASCADE,
1568
+ cursor jsonb,
1569
+ health text NOT NULL DEFAULT 'webhook_only'
1570
+ CHECK (health IN ('webhook_only','reconciled','degraded')),
1571
+ records_replayed bigint NOT NULL DEFAULT 0,
1572
+ last_started_at timestamptz,
1573
+ last_completed_at timestamptz,
1574
+ last_source_event_id text,
1575
+ last_error_code text,
1576
+ updated_at timestamptz NOT NULL DEFAULT now()
1577
+ )
1578
+ `);
1579
+ await client.query(`
1580
+ CREATE TABLE IF NOT EXISTS analytics_reconciliation_jobs (
1581
+ id uuid PRIMARY KEY,
1582
+ connection_id uuid NOT NULL UNIQUE REFERENCES analytics_connections(id) ON DELETE CASCADE,
1583
+ status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','leased')),
1584
+ attempts integer NOT NULL DEFAULT 0,
1585
+ next_attempt_at timestamptz NOT NULL DEFAULT now(),
1586
+ lease_until timestamptz,
1587
+ last_error_code text,
1588
+ created_at timestamptz NOT NULL DEFAULT now(),
1589
+ updated_at timestamptz NOT NULL DEFAULT now()
1590
+ )
1591
+ `);
1592
+ await client.query(`
1593
+ CREATE TABLE IF NOT EXISTS analytics_event_definitions (
1594
+ id uuid PRIMARY KEY,
1595
+ site_id uuid NOT NULL REFERENCES analytics_sites(id) ON DELETE CASCADE,
1596
+ name text NOT NULL,
1597
+ event_name text NOT NULL,
1598
+ trigger_kind text NOT NULL CHECK (trigger_kind IN ('page','click','form','semantic','custom_server','call','crm_stage','transaction')),
1599
+ definition jsonb NOT NULL,
1600
+ version integer NOT NULL DEFAULT 1,
1601
+ status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','disabled','archived')),
1602
+ created_by_user_id bigint NOT NULL,
1603
+ created_at timestamptz NOT NULL DEFAULT now(),
1604
+ updated_at timestamptz NOT NULL DEFAULT now(),
1605
+ UNIQUE(site_id, name)
1606
+ )
1607
+ `);
1608
+ await client.query(`
1609
+ DO $$
1610
+ BEGIN
1611
+ IF EXISTS (
1612
+ SELECT 1
1613
+ FROM pg_constraint
1614
+ WHERE conrelid = 'analytics_event_definitions'::regclass
1615
+ AND conname = 'analytics_event_definitions_trigger_kind_check'
1616
+ AND pg_get_constraintdef(oid) NOT LIKE '%semantic%'
1617
+ ) THEN
1618
+ ALTER TABLE analytics_event_definitions
1619
+ DROP CONSTRAINT analytics_event_definitions_trigger_kind_check;
1620
+ ALTER TABLE analytics_event_definitions
1621
+ ADD CONSTRAINT analytics_event_definitions_trigger_kind_check
1622
+ CHECK (trigger_kind IN ('page','click','form','semantic','custom_server','call','crm_stage','transaction'));
1623
+ END IF;
1624
+ END $$
1625
+ `);
1626
+ await client.query(`
1627
+ CREATE TABLE IF NOT EXISTS analytics_conversion_rules (
1628
+ id uuid PRIMARY KEY,
1629
+ site_id uuid NOT NULL REFERENCES analytics_sites(id) ON DELETE CASCADE,
1630
+ name text NOT NULL,
1631
+ conversion_kind text NOT NULL,
1632
+ condition jsonb NOT NULL,
1633
+ default_value_minor bigint,
1634
+ default_currency text NOT NULL DEFAULT 'USD',
1635
+ version integer NOT NULL DEFAULT 1,
1636
+ status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','disabled','archived')),
1637
+ created_by_user_id bigint NOT NULL,
1638
+ created_at timestamptz NOT NULL DEFAULT now(),
1639
+ updated_at timestamptz NOT NULL DEFAULT now(),
1640
+ UNIQUE(site_id, name)
1641
+ )
1642
+ `);
1643
+ await client.query(`
1644
+ CREATE TABLE IF NOT EXISTS analytics_restricted_data (
1645
+ id uuid PRIMARY KEY,
1646
+ site_id uuid NOT NULL REFERENCES analytics_sites(id) ON DELETE CASCADE,
1647
+ person_id uuid REFERENCES analytics_people(id) ON DELETE CASCADE,
1648
+ purpose text NOT NULL,
1649
+ subject_ref text NOT NULL,
1650
+ payload_ciphertext text NOT NULL,
1651
+ expires_at timestamptz NOT NULL,
1652
+ created_at timestamptz NOT NULL DEFAULT now(),
1653
+ UNIQUE(site_id, purpose, subject_ref)
1654
+ )
1655
+ `);
1656
+ await client.query(
1657
+ `ALTER TABLE analytics_activation_destinations ADD COLUMN IF NOT EXISTS readiness text NOT NULL DEFAULT 'configured_unverified' CHECK (readiness IN ('not_configured','configured_unverified','verified','live','degraded','disabled'))`
1658
+ );
1659
+ await client.query(
1660
+ `ALTER TABLE analytics_activation_destinations ADD COLUMN IF NOT EXISTS last_tested_at timestamptz`
1661
+ );
1662
+ await client.query(
1663
+ `ALTER TABLE analytics_activation_destinations ADD COLUMN IF NOT EXISTS last_receipt_id text`
1664
+ );
1665
+ await client.query(
1666
+ `ALTER TABLE analytics_activation_destinations ADD COLUMN IF NOT EXISTS provider_config jsonb NOT NULL DEFAULT '{}'::jsonb`
1667
+ );
1668
+ await client.query(
1669
+ `ALTER TABLE analytics_activation_destinations ADD COLUMN IF NOT EXISTS mapping_version integer NOT NULL DEFAULT 1`
1670
+ );
1671
+ await client.query(
1672
+ `ALTER TABLE analytics_activation_jobs ADD COLUMN IF NOT EXISTS lease_until timestamptz`
1673
+ );
1674
+ await client.query(
1675
+ `ALTER TABLE analytics_activation_jobs ADD COLUMN IF NOT EXISTS mapping_version integer NOT NULL DEFAULT 1`
1676
+ );
1677
+ await client.query(
1678
+ `ALTER TABLE analytics_activation_jobs ADD COLUMN IF NOT EXISTS external_receipt_id text`
1679
+ );
1680
+ await client.query(
1681
+ `ALTER TABLE analytics_activation_jobs ADD COLUMN IF NOT EXISTS response_code integer`
1682
+ );
1683
+ await client.query(
1684
+ `ALTER TABLE analytics_activation_jobs ADD COLUMN IF NOT EXISTS response_category text`
1685
+ );
1686
+ await client.query(
1687
+ `ALTER TABLE analytics_activation_jobs ADD COLUMN IF NOT EXISTS dead_lettered_at timestamptz`
1688
+ );
1689
+ await client.query(`
1690
+ CREATE TABLE IF NOT EXISTS analytics_activation_destination_tests (
1691
+ id uuid PRIMARY KEY,
1692
+ destination_id uuid NOT NULL REFERENCES analytics_activation_destinations(id) ON DELETE CASCADE,
1693
+ mapping_version integer NOT NULL,
1694
+ provider_request_id text,
1695
+ provider_event_id text,
1696
+ warnings jsonb NOT NULL DEFAULT '[]'::jsonb,
1697
+ safe_error_category text,
1698
+ request_started_at timestamptz NOT NULL DEFAULT now(),
1699
+ response_received_at timestamptz,
1700
+ next_diagnostic_at timestamptz,
1701
+ actor text NOT NULL,
1702
+ accepted boolean NOT NULL DEFAULT false,
1703
+ validation_only boolean NOT NULL DEFAULT false,
1704
+ created_at timestamptz NOT NULL DEFAULT now()
1705
+ )
1706
+ `);
1707
+ await client.query(`
1708
+ CREATE TABLE IF NOT EXISTS analytics_activation_delivery_attempts (
1709
+ id uuid PRIMARY KEY,
1710
+ job_id uuid NOT NULL REFERENCES analytics_activation_jobs(id) ON DELETE CASCADE,
1711
+ destination_id uuid NOT NULL REFERENCES analytics_activation_destinations(id) ON DELETE CASCADE,
1712
+ mapping_version integer NOT NULL,
1713
+ attempt_number integer NOT NULL,
1714
+ provider_request_id text,
1715
+ provider_event_id text,
1716
+ warnings jsonb NOT NULL DEFAULT '[]'::jsonb,
1717
+ safe_error_category text,
1718
+ request_started_at timestamptz NOT NULL DEFAULT now(),
1719
+ response_received_at timestamptz,
1720
+ next_diagnostic_at timestamptz,
1721
+ actor text NOT NULL,
1722
+ accepted boolean NOT NULL DEFAULT false,
1723
+ created_at timestamptz NOT NULL DEFAULT now(),
1724
+ UNIQUE(job_id, attempt_number)
1725
+ )
1726
+ `);
1727
+ await client.query(
1728
+ `ALTER TABLE analytics_crm_import_rows ADD COLUMN IF NOT EXISTS next_attempt_at timestamptz NOT NULL DEFAULT now()`
1729
+ );
1730
+ await client.query(
1731
+ `ALTER TABLE analytics_crm_import_rows ADD COLUMN IF NOT EXISTS lease_until timestamptz`
1732
+ );
1733
+ await client.query(
1734
+ `ALTER TABLE analytics_crm_import_rows ADD COLUMN IF NOT EXISTS expires_at timestamptz NOT NULL DEFAULT (now() + interval '7 days')`
1735
+ );
1736
+ await client.query(
1737
+ `ALTER TABLE analytics_conversions ADD COLUMN IF NOT EXISTS rule_id uuid REFERENCES analytics_conversion_rules(id) ON DELETE SET NULL`
1738
+ );
1739
+ await client.query(
1740
+ `ALTER TABLE analytics_conversions ADD COLUMN IF NOT EXISTS rule_version integer`
1741
+ );
1742
+ await client.query(
1743
+ `ALTER TABLE analytics_conversions ADD COLUMN IF NOT EXISTS order_id text`
1744
+ );
1745
+ await client.query(
1746
+ `ALTER TABLE analytics_connections ADD COLUMN IF NOT EXISTS verification_evidence text CHECK (verification_evidence IN ('synthetic','provider'))`
1747
+ );
1748
+ await client.query(
1749
+ `ALTER TABLE analytics_connections ADD COLUMN IF NOT EXISTS last_verified_receipt_id uuid`
1750
+ );
599
1751
  await client.query(`CREATE UNIQUE INDEX IF NOT EXISTS analytics_exports_owner_idempotency
600
1752
  ON analytics_exports(requested_by_user_id, idempotency_key) WHERE idempotency_key IS NOT NULL`);
601
1753
  await client.query(
@@ -613,9 +1765,42 @@ async function migrateAnalytics() {
613
1765
  await client.query(
614
1766
  "CREATE INDEX IF NOT EXISTS analytics_conversions_site_time ON analytics_conversions(site_id, occurred_at DESC)"
615
1767
  );
1768
+ await client.query(
1769
+ "CREATE INDEX IF NOT EXISTS analytics_sessions_site_visitor_time ON analytics_sessions(site_id, visitor_hmac, started_at DESC)"
1770
+ );
1771
+ await client.query(
1772
+ "CREATE INDEX IF NOT EXISTS analytics_touches_site_person_time ON analytics_attribution_touches(site_id, person_id, occurred_at)"
1773
+ );
1774
+ await client.query(
1775
+ "CREATE INDEX IF NOT EXISTS analytics_external_events_site_time ON analytics_external_events(site_id, occurred_at DESC)"
1776
+ );
1777
+ await client.query(
1778
+ "CREATE INDEX IF NOT EXISTS analytics_webhook_receipts_reconcile ON analytics_webhook_receipts(connection_id, received_at DESC)"
1779
+ );
1780
+ await client.query(
1781
+ "CREATE INDEX IF NOT EXISTS analytics_reconciliation_jobs_due ON analytics_reconciliation_jobs(status, next_attempt_at, lease_until)"
1782
+ );
1783
+ await client.query(
1784
+ "CREATE INDEX IF NOT EXISTS analytics_activation_jobs_due ON analytics_activation_jobs(status, next_attempt_at) WHERE status = 'pending'"
1785
+ );
1786
+ await client.query(
1787
+ "CREATE INDEX IF NOT EXISTS analytics_activation_tests_destination_time ON analytics_activation_destination_tests(destination_id, created_at DESC)"
1788
+ );
1789
+ await client.query(
1790
+ "CREATE INDEX IF NOT EXISTS analytics_activation_attempts_destination_time ON analytics_activation_delivery_attempts(destination_id, created_at DESC)"
1791
+ );
1792
+ await client.query(
1793
+ "CREATE INDEX IF NOT EXISTS analytics_activation_tests_diagnostics_due ON analytics_activation_destination_tests(next_diagnostic_at) WHERE next_diagnostic_at IS NOT NULL"
1794
+ );
1795
+ await client.query(
1796
+ "CREATE INDEX IF NOT EXISTS analytics_restricted_data_expiry ON analytics_restricted_data(expires_at)"
1797
+ );
1798
+ await client.query(
1799
+ "CREATE INDEX IF NOT EXISTS analytics_linker_tokens_expiry ON analytics_linker_tokens(expires_at) WHERE consumed_at IS NULL"
1800
+ );
616
1801
  await client.query(`
617
1802
  INSERT INTO analytics_schema_migrations(version)
618
- 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')
1803
+ 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'), ('2026-08-26.1'), ('2026-08-26.2'), ('2026-08-26.3'), ('2026-08-26.4')
619
1804
  ON CONFLICT (version) DO NOTHING
620
1805
  `);
621
1806
  await client.query("COMMIT");
@@ -670,10 +1855,16 @@ async function requireEditor(client, siteId, userId) {
670
1855
  );
671
1856
  return role;
672
1857
  }
1858
+ async function requireAnalyticsAccess(client, siteId, userId) {
1859
+ return accessFor(client, siteId, userId);
1860
+ }
1861
+ async function requireAnalyticsEditor(client, siteId, userId) {
1862
+ return requireEditor(client, siteId, userId);
1863
+ }
673
1864
  async function createAnalyticsSite(input) {
674
1865
  const db = getAnalyticsPool();
675
1866
  const client = await db.connect();
676
- const id = randomUUID();
1867
+ const id = randomUUID2();
677
1868
  const baseSlug = normalizeSlug(input.slug || input.name);
678
1869
  try {
679
1870
  await client.query("BEGIN");
@@ -904,7 +2095,7 @@ async function createAnalyticsPixel(input) {
904
2095
  VALUES ($1, $2, $3, $4, $5)
905
2096
  RETURNING id, site_id, public_id, name, environment, status, created_at::text, NULL::text AS last_event_at`,
906
2097
  [
907
- randomUUID(),
2098
+ randomUUID2(),
908
2099
  input.siteId,
909
2100
  publicPixelId(),
910
2101
  input.name.trim(),
@@ -976,19 +2167,244 @@ async function setAnalyticsPixelDomainState(input) {
976
2167
  "invalid_hostname",
977
2168
  "Enter a valid hostname."
978
2169
  );
979
- const result = await db.query(
980
- `INSERT INTO analytics_pixel_domains(id, pixel_id, hostname, state)
981
- SELECT $1, p.id, $4, $5 FROM analytics_pixels p WHERE p.id = $2 AND p.site_id = $3
982
- ON CONFLICT(pixel_id, hostname) DO UPDATE SET state = EXCLUDED.state, updated_at = now()
983
- RETURNING id`,
984
- [randomUUID(), input.pixelId, input.siteId, hostname, input.state]
985
- );
986
- if (!result.rowCount)
987
- throw new AnalyticsRepositoryError(
988
- "analytics_pixel_not_found",
989
- "Pixel not found.",
990
- 404
2170
+ const result = await db.query(
2171
+ `INSERT INTO analytics_pixel_domains(id, pixel_id, hostname, state)
2172
+ SELECT $1, p.id, $4, $5 FROM analytics_pixels p WHERE p.id = $2 AND p.site_id = $3
2173
+ ON CONFLICT(pixel_id, hostname) DO UPDATE SET state = EXCLUDED.state, updated_at = now()
2174
+ RETURNING id`,
2175
+ [randomUUID2(), input.pixelId, input.siteId, hostname, input.state]
2176
+ );
2177
+ if (!result.rowCount)
2178
+ throw new AnalyticsRepositoryError(
2179
+ "analytics_pixel_not_found",
2180
+ "Pixel not found.",
2181
+ 404
2182
+ );
2183
+ }
2184
+ async function upsertAnalyticsHostGroup(input) {
2185
+ const client = await getAnalyticsPool().connect();
2186
+ try {
2187
+ await client.query("BEGIN");
2188
+ await requireEditor(client, input.siteId, input.userId);
2189
+ const hostnames = [...new Set(input.hostnames.map(
2190
+ (hostname) => normalizeObservedHostname(`https://${hostname}`)
2191
+ ).filter((hostname) => Boolean(hostname)))];
2192
+ if (hostnames.length < 2 || hostnames.length > 20)
2193
+ throw new AnalyticsRepositoryError(
2194
+ "invalid_host_group",
2195
+ "A host group requires between two and twenty valid hostnames."
2196
+ );
2197
+ const approved = await client.query(
2198
+ `SELECT d.hostname
2199
+ FROM analytics_pixels p
2200
+ JOIN analytics_pixel_domains d ON d.pixel_id=p.id
2201
+ WHERE p.id=$1 AND p.site_id=$2 AND p.status='active'
2202
+ AND d.state='approved' AND d.hostname = ANY($3::text[])`,
2203
+ [input.pixelId, input.siteId, hostnames]
2204
+ );
2205
+ if (approved.rows.length !== hostnames.length)
2206
+ throw new AnalyticsRepositoryError(
2207
+ "host_group_unapproved",
2208
+ "Every host group member must be approved for this active Pixel.",
2209
+ 403
2210
+ );
2211
+ const group = await client.query(
2212
+ `INSERT INTO analytics_host_groups(id, site_id, pixel_id, name, auto_decorate)
2213
+ VALUES ($1,$2,$3,$4,$5)
2214
+ ON CONFLICT(pixel_id, name) DO UPDATE SET
2215
+ auto_decorate=EXCLUDED.auto_decorate, updated_at=now()
2216
+ RETURNING id`,
2217
+ [randomUUID2(), input.siteId, input.pixelId, input.name.trim().slice(0, 120), Boolean(input.autoDecorate)]
2218
+ );
2219
+ await client.query("DELETE FROM analytics_host_group_members WHERE group_id=$1", [group.rows[0].id]);
2220
+ for (const hostname of hostnames)
2221
+ await client.query(
2222
+ "INSERT INTO analytics_host_group_members(group_id, hostname) VALUES ($1,$2)",
2223
+ [group.rows[0].id, hostname]
2224
+ );
2225
+ await client.query("COMMIT");
2226
+ return {
2227
+ id: group.rows[0].id,
2228
+ siteId: input.siteId,
2229
+ pixelId: input.pixelId,
2230
+ name: input.name.trim().slice(0, 120),
2231
+ autoDecorate: Boolean(input.autoDecorate),
2232
+ hostnames
2233
+ };
2234
+ } catch (error) {
2235
+ await client.query("ROLLBACK");
2236
+ throw error;
2237
+ } finally {
2238
+ client.release();
2239
+ }
2240
+ }
2241
+ async function listAnalyticsHostGroups(input) {
2242
+ const db = getAnalyticsPool();
2243
+ await accessFor(db, input.siteId, input.userId);
2244
+ const result = await db.query(
2245
+ `SELECT g.id, g.site_id, g.pixel_id, g.name, g.auto_decorate,
2246
+ COALESCE(array_agg(m.hostname ORDER BY m.hostname) FILTER (WHERE m.hostname IS NOT NULL), '{}') AS hostnames
2247
+ FROM analytics_host_groups g
2248
+ LEFT JOIN analytics_host_group_members m ON m.group_id=g.id
2249
+ WHERE g.site_id=$1 AND g.pixel_id=$2
2250
+ GROUP BY g.id ORDER BY g.created_at`,
2251
+ [input.siteId, input.pixelId]
2252
+ );
2253
+ return result.rows.map((row) => ({
2254
+ id: row.id,
2255
+ siteId: row.site_id,
2256
+ pixelId: row.pixel_id,
2257
+ name: row.name,
2258
+ autoDecorate: row.auto_decorate,
2259
+ hostnames: row.hostnames
2260
+ }));
2261
+ }
2262
+ async function prepareAnalyticsLinkerIssue(input) {
2263
+ const client = await getAnalyticsPool().connect();
2264
+ const sourceHostname = normalizeObservedHostname(`https://${input.sourceHostname}`);
2265
+ const audienceHostname = normalizeObservedHostname(`https://${input.audienceHostname}`);
2266
+ if (!sourceHostname || !audienceHostname || sourceHostname === audienceHostname)
2267
+ throw new AnalyticsRepositoryError("linker_host_invalid", "Choose a different approved destination hostname.", 403);
2268
+ try {
2269
+ await client.query("BEGIN");
2270
+ const context = await client.query(
2271
+ `SELECT p.site_id, p.id AS pixel_id, g.id AS host_group_id
2272
+ FROM analytics_pixels p
2273
+ JOIN analytics_host_groups g ON g.pixel_id=p.id AND g.site_id=p.site_id
2274
+ JOIN analytics_host_group_members source ON source.group_id=g.id AND source.hostname=$2
2275
+ JOIN analytics_host_group_members audience ON audience.group_id=g.id AND audience.hostname=$3
2276
+ JOIN analytics_pixel_domains source_domain ON source_domain.pixel_id=p.id AND source_domain.hostname=$2 AND source_domain.state='approved'
2277
+ JOIN analytics_pixel_domains audience_domain ON audience_domain.pixel_id=p.id AND audience_domain.hostname=$3 AND audience_domain.state='approved'
2278
+ WHERE p.public_id=$1 AND p.status='active'
2279
+ AND NOT EXISTS (
2280
+ SELECT 1 FROM analytics_host_group_members member
2281
+ LEFT JOIN analytics_pixel_domains member_domain
2282
+ ON member_domain.pixel_id=p.id AND member_domain.hostname=member.hostname
2283
+ WHERE member.group_id=g.id AND COALESCE(member_domain.state, '') <> 'approved'
2284
+ )
2285
+ LIMIT 1`,
2286
+ [input.publicPixelId, sourceHostname, audienceHostname]
2287
+ );
2288
+ if (!context.rowCount)
2289
+ throw new AnalyticsRepositoryError(
2290
+ "linker_host_unapproved",
2291
+ "Both hostnames must belong to the same approved Pixel host group.",
2292
+ 403
2293
+ );
2294
+ const { site_id: siteId, pixel_id: pixelId, host_group_id: hostGroupId } = context.rows[0];
2295
+ const node = await client.query(
2296
+ `INSERT INTO analytics_identity_nodes(id, site_id, kind, value_hmac)
2297
+ VALUES ($1,$2,'visitor_id',$3)
2298
+ ON CONFLICT(site_id, kind, value_hmac) DO UPDATE SET last_seen_at=now()
2299
+ RETURNING id`,
2300
+ [randomUUID2(), siteId, identityHmac("visitor_id", input.visitorId)]
2301
+ );
2302
+ const existingPerson = await client.query(
2303
+ `SELECT person_id FROM analytics_identity_edges
2304
+ WHERE site_id=$1 AND identity_node_id=$2
2305
+ ORDER BY confidence DESC, first_seen_at ASC LIMIT 1`,
2306
+ [siteId, node.rows[0].id]
2307
+ );
2308
+ let personId = existingPerson.rows[0]?.person_id;
2309
+ if (!personId) {
2310
+ const person = await client.query(
2311
+ `INSERT INTO analytics_people(id, site_id, crm_person_ref) VALUES ($1,$2,$3)
2312
+ ON CONFLICT(site_id, crm_person_ref) DO UPDATE SET last_seen_at=now() RETURNING id`,
2313
+ [randomUUID2(), siteId, `XRay::anonymous::${identityHmac("visitor_id", input.visitorId).slice(0, 24)}`]
2314
+ );
2315
+ personId = person.rows[0].id;
2316
+ await client.query(
2317
+ `INSERT INTO analytics_identity_edges(id, site_id, person_id, identity_node_id, evidence_kind, confidence)
2318
+ VALUES ($1,$2,$3,$4,'first_party_linker',0.980)
2319
+ ON CONFLICT(person_id, identity_node_id, evidence_kind) DO UPDATE SET last_seen_at=now()`,
2320
+ [randomUUID2(), siteId, personId, node.rows[0].id]
2321
+ );
2322
+ }
2323
+ const tokenId = randomUUID2();
2324
+ await client.query(
2325
+ `INSERT INTO analytics_linker_tokens(
2326
+ id, site_id, pixel_id, host_group_id, identity_node_id,
2327
+ source_hostname, audience_hostname, expires_at
2328
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
2329
+ [tokenId, siteId, pixelId, hostGroupId, node.rows[0].id, sourceHostname, audienceHostname, input.expiresAt]
2330
+ );
2331
+ await client.query("COMMIT");
2332
+ return {
2333
+ tokenId,
2334
+ siteId,
2335
+ pixelId,
2336
+ identityNodeId: node.rows[0].id,
2337
+ sourceHostname,
2338
+ audienceHostname,
2339
+ expiresAt: input.expiresAt
2340
+ };
2341
+ } catch (error) {
2342
+ await client.query("ROLLBACK");
2343
+ throw error;
2344
+ } finally {
2345
+ client.release();
2346
+ }
2347
+ }
2348
+ async function redeemAnalyticsLinkerRecord(input) {
2349
+ const client = await getAnalyticsPool().connect();
2350
+ try {
2351
+ await client.query("BEGIN");
2352
+ const redeemed = await client.query(
2353
+ `UPDATE analytics_linker_tokens SET consumed_at=now()
2354
+ WHERE id=$1 AND site_id=$2 AND pixel_id=$3 AND identity_node_id=$4
2355
+ AND audience_hostname=$5 AND consumed_at IS NULL AND expires_at > now()
2356
+ RETURNING identity_node_id`,
2357
+ [input.tokenId, input.siteId, input.pixelId, input.identityNodeId, input.audienceHostname]
2358
+ );
2359
+ if (!redeemed.rowCount)
2360
+ throw new AnalyticsRepositoryError(
2361
+ "linker_replayed_or_expired",
2362
+ "The linker token was already used or has expired.",
2363
+ 409
2364
+ );
2365
+ const sourceEdge = await client.query(
2366
+ `SELECT person_id FROM analytics_identity_edges
2367
+ WHERE site_id=$1 AND identity_node_id=$2
2368
+ ORDER BY confidence DESC, first_seen_at ASC LIMIT 1`,
2369
+ [input.siteId, input.identityNodeId]
2370
+ );
2371
+ const personId = sourceEdge.rows[0]?.person_id;
2372
+ if (!personId)
2373
+ throw new AnalyticsRepositoryError("linker_identity_missing", "The linker identity is unavailable.", 409);
2374
+ const targetNode = await client.query(
2375
+ `INSERT INTO analytics_identity_nodes(id, site_id, kind, value_hmac)
2376
+ VALUES ($1,$2,'visitor_id',$3)
2377
+ ON CONFLICT(site_id, kind, value_hmac) DO UPDATE SET last_seen_at=now()
2378
+ RETURNING id`,
2379
+ [randomUUID2(), input.siteId, identityHmac("visitor_id", input.visitorId)]
2380
+ );
2381
+ await client.query(
2382
+ `INSERT INTO analytics_identity_edges(id, site_id, person_id, identity_node_id, evidence_kind, confidence)
2383
+ VALUES ($1,$2,$3,$4,'cross_domain_linker',0.980)
2384
+ ON CONFLICT(person_id, identity_node_id, evidence_kind) DO UPDATE SET last_seen_at=now()`,
2385
+ [randomUUID2(), input.siteId, personId, targetNode.rows[0].id]
2386
+ );
2387
+ await client.query(
2388
+ "UPDATE analytics_events SET person_id=$1 WHERE site_id=$2 AND visitor_id=$3 AND person_id IS NULL",
2389
+ [personId, input.siteId, input.visitorId]
2390
+ );
2391
+ const visitorHmac = identityHmac("visitor_id", input.visitorId);
2392
+ await client.query(
2393
+ "UPDATE analytics_sessions SET person_id=$1 WHERE site_id=$2 AND visitor_hmac=$3 AND person_id IS NULL",
2394
+ [personId, input.siteId, visitorHmac]
2395
+ );
2396
+ await client.query(
2397
+ "UPDATE analytics_attribution_touches SET person_id=$1 WHERE site_id=$2 AND visitor_hmac=$3 AND person_id IS NULL",
2398
+ [personId, input.siteId, visitorHmac]
991
2399
  );
2400
+ await client.query("COMMIT");
2401
+ return { personId };
2402
+ } catch (error) {
2403
+ await client.query("ROLLBACK");
2404
+ throw error;
2405
+ } finally {
2406
+ client.release();
2407
+ }
992
2408
  }
993
2409
  var MAX_ENGAGED_MS = 30 * 60 * 1e3;
994
2410
  var ENGAGED_SESSION_MS = 1e4;
@@ -1032,9 +2448,94 @@ function sanitizeAnalyticsProperties(value) {
1032
2448
  }
1033
2449
  return output;
1034
2450
  }
2451
+ async function materializeAnalyticsSessionAndTouch(input) {
2452
+ if (!input.event.visitorId || !input.event.sessionId) return;
2453
+ const visitorHmac = identityHmac("visitor_id", input.event.visitorId);
2454
+ const sessionHmac = identityHmac("session_id", input.event.sessionId);
2455
+ const occurredAt = new Date(input.event.occurredAt);
2456
+ if (!Number.isFinite(occurredAt.getTime())) return;
2457
+ const path = normalizeAnalyticsPath(input.event.path || input.event.canonicalUrl);
2458
+ const session = await input.client.query(
2459
+ `INSERT INTO analytics_sessions(
2460
+ id, site_id, pixel_id, visitor_hmac, session_hmac, person_id,
2461
+ started_at, last_active_at, landing_path, exit_path, pageview_count, event_count
2462
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,$7,$8,$8,$9,1)
2463
+ ON CONFLICT(site_id, session_hmac) DO UPDATE SET
2464
+ person_id = COALESCE(analytics_sessions.person_id, EXCLUDED.person_id),
2465
+ last_active_at = GREATEST(analytics_sessions.last_active_at, EXCLUDED.last_active_at),
2466
+ ended_at = GREATEST(COALESCE(analytics_sessions.ended_at, EXCLUDED.last_active_at), EXCLUDED.last_active_at),
2467
+ duration_ms = GREATEST(
2468
+ analytics_sessions.duration_ms,
2469
+ LEAST(1800000, (EXTRACT(EPOCH FROM (GREATEST(analytics_sessions.last_active_at, EXCLUDED.last_active_at) - analytics_sessions.started_at)) * 1000)::bigint)
2470
+ ),
2471
+ exit_path = EXCLUDED.exit_path,
2472
+ pageview_count = analytics_sessions.pageview_count + EXCLUDED.pageview_count,
2473
+ event_count = analytics_sessions.event_count + 1,
2474
+ updated_at = now()
2475
+ RETURNING id`,
2476
+ [
2477
+ randomUUID2(),
2478
+ input.siteId,
2479
+ input.pixelId,
2480
+ visitorHmac,
2481
+ sessionHmac,
2482
+ input.personId ?? null,
2483
+ input.event.occurredAt,
2484
+ path,
2485
+ input.event.eventName === "page_view" ? 1 : 0
2486
+ ]
2487
+ );
2488
+ const clickIds = sanitizeClickIds(input.event.clickIds);
2489
+ const hasTouch = Boolean(
2490
+ input.event.source || input.event.medium || input.event.campaign || input.event.referrer || Object.keys(clickIds).length
2491
+ );
2492
+ if (!hasTouch) return;
2493
+ const touchHash = createHash("sha256").update(
2494
+ JSON.stringify({
2495
+ siteId: input.siteId,
2496
+ sessionHmac,
2497
+ source: input.event.source ?? null,
2498
+ medium: input.event.medium ?? null,
2499
+ campaign: input.event.campaign ?? null,
2500
+ referrer: normalizeAnalyticsUrl(input.event.referrer),
2501
+ clickIds,
2502
+ path
2503
+ })
2504
+ ).digest("hex");
2505
+ const touch = await input.client.query(
2506
+ `INSERT INTO analytics_attribution_touches(
2507
+ id, site_id, session_id, person_id, visitor_hmac, occurred_at,
2508
+ source, medium, campaign, referrer, click_ids, landing_path, touch_hash
2509
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12,$13)
2510
+ ON CONFLICT(site_id, touch_hash) DO UPDATE SET
2511
+ person_id = COALESCE(analytics_attribution_touches.person_id, EXCLUDED.person_id)
2512
+ RETURNING id`,
2513
+ [
2514
+ randomUUID2(),
2515
+ input.siteId,
2516
+ session.rows[0].id,
2517
+ input.personId ?? null,
2518
+ visitorHmac,
2519
+ input.event.occurredAt,
2520
+ input.event.source?.slice(0, 180) ?? null,
2521
+ input.event.medium?.slice(0, 180) ?? null,
2522
+ input.event.campaign?.slice(0, 240) ?? null,
2523
+ normalizeAnalyticsUrl(input.event.referrer),
2524
+ JSON.stringify(clickIds),
2525
+ path,
2526
+ touchHash
2527
+ ]
2528
+ );
2529
+ await input.client.query(
2530
+ `UPDATE analytics_sessions
2531
+ SET first_touch_id = COALESCE(first_touch_id, $2), last_touch_id = $2, updated_at = now()
2532
+ WHERE id = $1`,
2533
+ [session.rows[0].id, touch.rows[0].id]
2534
+ );
2535
+ }
1035
2536
  async function ingestAnalyticsEvents(input) {
1036
2537
  const db = getAnalyticsPool();
1037
- const requestId = `air_${randomUUID()}`;
2538
+ const requestId = `air_${randomUUID2()}`;
1038
2539
  const hostname = normalizeObservedHostname(input.origin);
1039
2540
  const pixelResult = await db.query(
1040
2541
  `SELECT p.id, p.site_id, p.status,
@@ -1055,7 +2556,7 @@ async function ingestAnalyticsEvents(input) {
1055
2556
  `INSERT INTO analytics_ingestion_receipts(id, request_id, site_id, pixel_id, hostname, rejected_count, reason_codes)
1056
2557
  VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`,
1057
2558
  [
1058
- randomUUID(),
2559
+ randomUUID2(),
1059
2560
  requestId,
1060
2561
  pixel?.site_id ?? null,
1061
2562
  pixel?.id ?? null,
@@ -1080,7 +2581,7 @@ async function ingestAnalyticsEvents(input) {
1080
2581
  `INSERT INTO analytics_ingestion_receipts(id, request_id, site_id, pixel_id, hostname, rejected_count, reason_codes)
1081
2582
  VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`,
1082
2583
  [
1083
- randomUUID(),
2584
+ randomUUID2(),
1084
2585
  requestId,
1085
2586
  pixel.site_id,
1086
2587
  pixel.id,
@@ -1105,7 +2606,7 @@ async function ingestAnalyticsEvents(input) {
1105
2606
  `INSERT INTO analytics_ingestion_receipts(id, request_id, site_id, pixel_id, rejected_count, reason_codes)
1106
2607
  VALUES ($1, $2, $3, $4, $5, $6::jsonb)`,
1107
2608
  [
1108
- randomUUID(),
2609
+ randomUUID2(),
1109
2610
  requestId,
1110
2611
  pixel.site_id,
1111
2612
  pixel.id,
@@ -1129,7 +2630,7 @@ async function ingestAnalyticsEvents(input) {
1129
2630
  ON CONFLICT(pixel_id, hostname) DO UPDATE
1130
2631
  SET last_seen_at = now(), updated_at = now()
1131
2632
  RETURNING state`,
1132
- [randomUUID(), pixel.id, hostname]
2633
+ [randomUUID2(), pixel.id, hostname]
1133
2634
  );
1134
2635
  if (domainResult.rows[0]?.state !== "approved") {
1135
2636
  await db.query(
@@ -1143,7 +2644,7 @@ async function ingestAnalyticsEvents(input) {
1143
2644
  `INSERT INTO analytics_ingestion_receipts(id, request_id, site_id, pixel_id, hostname, rejected_count, reason_codes)
1144
2645
  VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`,
1145
2646
  [
1146
- randomUUID(),
2647
+ randomUUID2(),
1147
2648
  requestId,
1148
2649
  pixel.site_id,
1149
2650
  pixel.id,
@@ -1171,7 +2672,17 @@ async function ingestAnalyticsEvents(input) {
1171
2672
  for (const event of input.events) {
1172
2673
  try {
1173
2674
  const attribution = resolveAnalyticsAttribution(event);
1174
- const resolvedPerson = event.visitorId || event.sessionId ? await client.query(
2675
+ const identifiedPersonId = event.identity && event.consent?.analytics === "granted" ? await linkAnalyticsIdentityInTransaction(client, {
2676
+ siteId: pixel.site_id,
2677
+ visitorId: event.visitorId,
2678
+ sessionId: event.sessionId,
2679
+ email: event.identity.email,
2680
+ phone: event.identity.phone,
2681
+ customerId: event.identity.customerId,
2682
+ orderId: event.identity.orderId,
2683
+ clickIds: event.clickIds
2684
+ }) : null;
2685
+ const resolvedPerson = identifiedPersonId ? { rows: [{ person_id: identifiedPersonId }] } : event.visitorId || event.sessionId ? await client.query(
1175
2686
  `SELECT e.person_id FROM analytics_identity_nodes n JOIN analytics_identity_edges e ON e.identity_node_id = n.id
1176
2687
  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))
1177
2688
  ORDER BY e.confidence DESC, e.last_seen_at DESC LIMIT 1`,
@@ -1193,7 +2704,7 @@ async function ingestAnalyticsEvents(input) {
1193
2704
  $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33
1194
2705
  ) ON CONFLICT(site_id, event_id) DO NOTHING`,
1195
2706
  [
1196
- randomUUID(),
2707
+ randomUUID2(),
1197
2708
  event.eventId,
1198
2709
  pixel.site_id,
1199
2710
  pixel.id,
@@ -1228,8 +2739,16 @@ async function ingestAnalyticsEvents(input) {
1228
2739
  boundedScrollDepth(event.scrollDepth)
1229
2740
  ]
1230
2741
  );
1231
- if (inserted.rowCount) accepted += 1;
1232
- else {
2742
+ if (inserted.rowCount) {
2743
+ accepted += 1;
2744
+ await materializeAnalyticsSessionAndTouch({
2745
+ client,
2746
+ siteId: pixel.site_id,
2747
+ pixelId: pixel.id,
2748
+ personId: resolvedPerson.rows[0]?.person_id,
2749
+ event
2750
+ });
2751
+ } else {
1233
2752
  rejected += 1;
1234
2753
  reasonCodes.add("duplicate_event");
1235
2754
  }
@@ -1251,7 +2770,7 @@ async function ingestAnalyticsEvents(input) {
1251
2770
  id, request_id, site_id, pixel_id, hostname, accepted_count, rejected_count, reason_codes
1252
2771
  ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)`,
1253
2772
  [
1254
- randomUUID(),
2773
+ randomUUID2(),
1255
2774
  requestId,
1256
2775
  pixel.site_id,
1257
2776
  pixel.id,
@@ -1320,45 +2839,86 @@ function paginateRows(rows, input, fingerprint) {
1320
2839
  }
1321
2840
  async function createAnalyticsConversion(input) {
1322
2841
  const db = getAnalyticsPool();
1323
- await requireEditor(db, input.siteId, input.userId);
1324
- if (input.pixelId) {
1325
- const pixel = await db.query(
1326
- `SELECT 1 FROM analytics_pixels WHERE id = $1 AND site_id = $2`,
1327
- [input.pixelId, input.siteId]
2842
+ const client = await db.connect();
2843
+ try {
2844
+ await client.query("BEGIN");
2845
+ await requireEditor(client, input.siteId, input.userId);
2846
+ if (input.pixelId) {
2847
+ const pixel = await client.query(
2848
+ `SELECT 1 FROM analytics_pixels WHERE id = $1 AND site_id = $2`,
2849
+ [input.pixelId, input.siteId]
2850
+ );
2851
+ if (!pixel.rowCount)
2852
+ throw new AnalyticsRepositoryError(
2853
+ "analytics_pixel_not_found",
2854
+ "Pixel not found.",
2855
+ 404
2856
+ );
2857
+ }
2858
+ const resolvedPerson = input.personId ? { rows: [{ person_id: input.personId }] } : input.sessionId ? await client.query(
2859
+ `SELECT e.person_id FROM analytics_identity_nodes n JOIN analytics_identity_edges e ON e.identity_node_id = n.id
2860
+ WHERE n.site_id = $1 AND n.kind = 'session_id' AND n.value_hmac = $2
2861
+ ORDER BY e.confidence DESC LIMIT 1`,
2862
+ [input.siteId, identityHmac("session_id", input.sessionId)]
2863
+ ) : { rows: [] };
2864
+ const proposedId = randomUUID2();
2865
+ const inserted = await client.query(
2866
+ `INSERT INTO analytics_conversions(
2867
+ id, source_event_id, site_id, pixel_id, session_id, conversion_kind,
2868
+ value_minor, currency, occurred_at, person_id, order_id
2869
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
2870
+ ON CONFLICT(site_id, source_event_id) DO NOTHING RETURNING id`,
2871
+ [
2872
+ proposedId,
2873
+ input.sourceEventId,
2874
+ input.siteId,
2875
+ input.pixelId ?? null,
2876
+ input.sessionId ?? null,
2877
+ input.conversionKind,
2878
+ input.valueMinor ?? 0,
2879
+ (input.currency || "USD").toUpperCase(),
2880
+ input.occurredAt,
2881
+ resolvedPerson.rows[0]?.person_id ?? null,
2882
+ input.orderId ?? null
2883
+ ]
1328
2884
  );
1329
- if (!pixel.rowCount)
1330
- throw new AnalyticsRepositoryError(
1331
- "analytics_pixel_not_found",
1332
- "Pixel not found.",
1333
- 404
2885
+ const created = Boolean(inserted.rowCount);
2886
+ const conversionId = created ? inserted.rows[0].id : (await client.query(
2887
+ `SELECT id FROM analytics_conversions WHERE site_id = $1 AND source_event_id = $2`,
2888
+ [input.siteId, input.sourceEventId]
2889
+ )).rows[0].id;
2890
+ let queuedDestinations = 0;
2891
+ if (created && input.activationPayloadCiphertext) {
2892
+ const destinations = await client.query(
2893
+ `SELECT id FROM analytics_activation_destinations
2894
+ WHERE site_id = $1 AND status = 'active' AND readiness IN ('verified','live')`,
2895
+ [input.siteId]
1334
2896
  );
2897
+ for (const destination of destinations.rows) {
2898
+ const queued = await client.query(
2899
+ `INSERT INTO analytics_activation_jobs(
2900
+ id, destination_id, conversion_id, person_id, payload_ciphertext
2901
+ ) VALUES ($1,$2,$3,$4,$5)
2902
+ ON CONFLICT(destination_id, conversion_id) DO NOTHING`,
2903
+ [
2904
+ randomUUID2(),
2905
+ destination.id,
2906
+ conversionId,
2907
+ resolvedPerson.rows[0]?.person_id ?? null,
2908
+ input.activationPayloadCiphertext
2909
+ ]
2910
+ );
2911
+ queuedDestinations += queued.rowCount ?? 0;
2912
+ }
2913
+ }
2914
+ await client.query("COMMIT");
2915
+ return { id: conversionId, created, queuedDestinations };
2916
+ } catch (error) {
2917
+ await client.query("ROLLBACK");
2918
+ throw error;
2919
+ } finally {
2920
+ client.release();
1335
2921
  }
1336
- const id = randomUUID();
1337
- const resolvedPerson = input.sessionId ? await db.query(
1338
- `SELECT e.person_id FROM analytics_identity_nodes n JOIN analytics_identity_edges e ON e.identity_node_id = n.id
1339
- WHERE n.site_id = $1 AND n.kind = 'session_id' AND n.value_hmac = $2 ORDER BY e.confidence DESC LIMIT 1`,
1340
- [input.siteId, identityHmac("session_id", input.sessionId)]
1341
- ) : { rows: [] };
1342
- const result = await db.query(
1343
- `INSERT INTO analytics_conversions(
1344
- id, source_event_id, site_id, pixel_id, session_id, conversion_kind, value_minor, currency, occurred_at, person_id
1345
- ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
1346
- ON CONFLICT(site_id, source_event_id) DO NOTHING
1347
- RETURNING id`,
1348
- [
1349
- id,
1350
- input.sourceEventId,
1351
- input.siteId,
1352
- input.pixelId ?? null,
1353
- input.sessionId ?? null,
1354
- input.conversionKind,
1355
- input.valueMinor ?? 0,
1356
- (input.currency || "USD").toUpperCase(),
1357
- input.occurredAt,
1358
- resolvedPerson.rows[0]?.person_id ?? null
1359
- ]
1360
- );
1361
- return { id: result.rows[0]?.id ?? id, created: Boolean(result.rowCount) };
1362
2922
  }
1363
2923
  function eventFilterSql(siteId, filters) {
1364
2924
  const values = [siteId, filters.start, filters.end];
@@ -2114,7 +3674,7 @@ async function createAnalyticsCampaignLink(input) {
2114
3674
  ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
2115
3675
  RETURNING *, 0::int AS click_count`,
2116
3676
  [
2117
- randomUUID(),
3677
+ randomUUID2(),
2118
3678
  input.siteId,
2119
3679
  input.pixelId ?? null,
2120
3680
  input.name.trim(),
@@ -2185,7 +3745,7 @@ async function resolveAnalyticsCampaignLink(shortCode, referrer) {
2185
3745
  if (!row) return null;
2186
3746
  await db.query(
2187
3747
  `INSERT INTO analytics_campaign_clicks(id, link_id, referrer) VALUES ($1, $2, $3)`,
2188
- [randomUUID(), row.id, normalizeAnalyticsUrl(referrer || void 0)]
3748
+ [randomUUID2(), row.id, normalizeAnalyticsUrl(referrer || void 0)]
2189
3749
  );
2190
3750
  return buildTaggedCampaignUrl(row);
2191
3751
  }
@@ -2210,7 +3770,7 @@ async function createAnalyticsForm(input) {
2210
3770
  VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9,$10,$11,$12,$13,$14)
2211
3771
  RETURNING *, 0::int AS submission_count`,
2212
3772
  [
2213
- randomUUID(),
3773
+ randomUUID2(),
2214
3774
  publicId,
2215
3775
  input.siteId,
2216
3776
  input.pixelId,
@@ -2255,7 +3815,7 @@ async function getPublicAnalyticsForm(publicId) {
2255
3815
  return result.rows[0] ?? null;
2256
3816
  }
2257
3817
  async function recordAnalyticsFormSubmission(input) {
2258
- const id = randomUUID();
3818
+ const id = randomUUID2();
2259
3819
  await getAnalyticsPool().query(
2260
3820
  `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)
2261
3821
  VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb)`,
@@ -2283,7 +3843,11 @@ var clickIdKeys = /* @__PURE__ */ new Set([
2283
3843
  "wbraid",
2284
3844
  "ttclid",
2285
3845
  "rdt_cid",
2286
- "msclkid"
3846
+ "msclkid",
3847
+ "fbp",
3848
+ "fbc",
3849
+ "li_fat_id",
3850
+ "snapclid"
2287
3851
  ]);
2288
3852
  function sanitizeClickIds(value) {
2289
3853
  const output = {};
@@ -2297,6 +3861,47 @@ function sanitizeClickIds(value) {
2297
3861
  function identityHmac(kind, value) {
2298
3862
  return createHmac2("sha256", getSessionSecret()).update(`${kind}:${value.trim().toLowerCase()}`).digest("hex");
2299
3863
  }
3864
+ async function linkAnalyticsIdentityInTransaction(client, input) {
3865
+ const stable = input.customerId || input.orderId || input.email || input.phone;
3866
+ if (!stable) return null;
3867
+ const crmPersonRef = `XRay::${identityHmac("person_ref", stable).slice(0, 24)}`;
3868
+ const person = await client.query(
3869
+ `INSERT INTO analytics_people(id, site_id, crm_person_ref) VALUES ($1,$2,$3)
3870
+ ON CONFLICT(site_id, crm_person_ref) DO UPDATE SET last_seen_at = now() RETURNING id`,
3871
+ [randomUUID2(), input.siteId, crmPersonRef]
3872
+ );
3873
+ const personId = person.rows[0].id;
3874
+ const signals = [];
3875
+ if (input.visitorId)
3876
+ signals.push({ kind: "visitor_id", value: input.visitorId, evidence: "pixel_identify", confidence: 0.98 });
3877
+ if (input.sessionId)
3878
+ signals.push({ kind: "session_id", value: input.sessionId, evidence: "pixel_identify", confidence: 0.98 });
3879
+ if (input.email)
3880
+ signals.push({ kind: "email", value: input.email, evidence: "pixel_identify", confidence: 1 });
3881
+ if (input.phone)
3882
+ signals.push({ kind: "phone", value: input.phone, evidence: "pixel_identify", confidence: 0.99 });
3883
+ if (input.customerId)
3884
+ signals.push({ kind: "customer_id", value: input.customerId, evidence: "customer_id", confidence: 1 });
3885
+ if (input.orderId)
3886
+ signals.push({ kind: "customer_id", value: `order:${input.orderId}`, evidence: "order_id", confidence: 1 });
3887
+ for (const [platform, clickId] of Object.entries(sanitizeClickIds(input.clickIds)))
3888
+ signals.push({ kind: "click_id", value: `${platform}:${clickId}`, evidence: platform, confidence: 0.9 });
3889
+ for (const signal of signals) {
3890
+ const node = await client.query(
3891
+ `INSERT INTO analytics_identity_nodes(id, site_id, kind, value_hmac) VALUES ($1,$2,$3,$4)
3892
+ ON CONFLICT(site_id, kind, value_hmac) DO UPDATE SET last_seen_at = now() RETURNING id`,
3893
+ [randomUUID2(), input.siteId, signal.kind, identityHmac(signal.kind, signal.value)]
3894
+ );
3895
+ await client.query(
3896
+ `INSERT INTO analytics_identity_edges(id, site_id, person_id, identity_node_id, evidence_kind, confidence)
3897
+ VALUES ($1,$2,$3,$4,$5,$6)
3898
+ ON CONFLICT(person_id, identity_node_id, evidence_kind) DO UPDATE
3899
+ SET last_seen_at = now(), confidence = greatest(analytics_identity_edges.confidence, EXCLUDED.confidence)`,
3900
+ [randomUUID2(), input.siteId, personId, node.rows[0].id, signal.evidence, signal.confidence]
3901
+ );
3902
+ }
3903
+ return personId;
3904
+ }
2300
3905
  async function linkAnalyticsFormIdentity(input) {
2301
3906
  const client = await getAnalyticsPool().connect();
2302
3907
  try {
@@ -2304,7 +3909,7 @@ async function linkAnalyticsFormIdentity(input) {
2304
3909
  const person = await client.query(
2305
3910
  `INSERT INTO analytics_people(id, site_id, crm_person_ref) VALUES ($1,$2,$3)
2306
3911
  ON CONFLICT(site_id, crm_person_ref) DO UPDATE SET last_seen_at = now() RETURNING id`,
2307
- [randomUUID(), input.siteId, input.crmPersonRef]
3912
+ [randomUUID2(), input.siteId, input.crmPersonRef]
2308
3913
  );
2309
3914
  const personId = person.rows[0].id;
2310
3915
  const signals = [];
@@ -2351,7 +3956,7 @@ async function linkAnalyticsFormIdentity(input) {
2351
3956
  `INSERT INTO analytics_identity_nodes(id, site_id, kind, value_hmac) VALUES ($1,$2,$3,$4)
2352
3957
  ON CONFLICT(site_id, kind, value_hmac) DO UPDATE SET last_seen_at = now() RETURNING id`,
2353
3958
  [
2354
- randomUUID(),
3959
+ randomUUID2(),
2355
3960
  input.siteId,
2356
3961
  signal.kind,
2357
3962
  identityHmac(signal.kind, signal.value)
@@ -2362,7 +3967,7 @@ async function linkAnalyticsFormIdentity(input) {
2362
3967
  VALUES ($1,$2,$3,$4,$5,$6)
2363
3968
  ON CONFLICT(person_id, identity_node_id, evidence_kind) DO UPDATE SET last_seen_at = now(), confidence = greatest(analytics_identity_edges.confidence, EXCLUDED.confidence)`,
2364
3969
  [
2365
- randomUUID(),
3970
+ randomUUID2(),
2366
3971
  input.siteId,
2367
3972
  personId,
2368
3973
  node.rows[0].id,
@@ -2460,7 +4065,7 @@ async function createAnalyticsCrmImport(input) {
2460
4065
  const db = getAnalyticsPool();
2461
4066
  await requireEditor(db, input.siteId, input.userId);
2462
4067
  const client = await db.connect();
2463
- const id = randomUUID();
4068
+ const id = randomUUID2();
2464
4069
  try {
2465
4070
  await client.query("BEGIN");
2466
4071
  await client.query(
@@ -2480,7 +4085,7 @@ async function createAnalyticsCrmImport(input) {
2480
4085
  await client.query(
2481
4086
  `INSERT INTO analytics_crm_import_rows(id, import_id, crm_person_ref, payload_ciphertext)
2482
4087
  VALUES ($1,$2,$3,$4) ON CONFLICT(import_id, crm_person_ref) DO NOTHING`,
2483
- [randomUUID(), id, row.crmPersonRef, row.payloadCiphertext]
4088
+ [randomUUID2(), id, row.crmPersonRef, row.payloadCiphertext]
2484
4089
  );
2485
4090
  }
2486
4091
  await client.query("COMMIT");
@@ -2512,20 +4117,105 @@ async function listAnalyticsCrmImports(siteId, userId, page) {
2512
4117
  );
2513
4118
  return { imports: paged.items, pageInfo: paged.pageInfo };
2514
4119
  }
4120
+ async function claimAnalyticsCrmImportRows(limit = 10) {
4121
+ const result = await getAnalyticsPool().query(
4122
+ `WITH due AS (
4123
+ SELECT r.id FROM analytics_crm_import_rows r
4124
+ WHERE r.delivery_status='pending' AND r.next_attempt_at <= now()
4125
+ AND r.expires_at > now() AND (r.lease_until IS NULL OR r.lease_until < now())
4126
+ ORDER BY r.next_attempt_at, r.created_at FOR UPDATE SKIP LOCKED LIMIT $1
4127
+ )
4128
+ UPDATE analytics_crm_import_rows r SET attempts=r.attempts+1,
4129
+ lease_until=now()+interval '5 minutes'
4130
+ FROM due, analytics_crm_imports i WHERE r.id=due.id AND i.id=r.import_id
4131
+ RETURNING r.id, r.import_id, i.requested_by_user_id, r.payload_ciphertext, r.attempts`,
4132
+ [Math.min(Math.max(limit, 1), 50)]
4133
+ );
4134
+ return result.rows.map((row) => ({
4135
+ id: row.id,
4136
+ importId: row.import_id,
4137
+ ownerUserId: Number(row.requested_by_user_id),
4138
+ payloadCiphertext: row.payload_ciphertext,
4139
+ attempts: row.attempts
4140
+ }));
4141
+ }
4142
+ async function completeAnalyticsCrmImportRow(rowId, importId) {
4143
+ const db = getAnalyticsPool();
4144
+ const client = await db.connect();
4145
+ try {
4146
+ await client.query("BEGIN");
4147
+ await client.query(
4148
+ `UPDATE analytics_crm_import_rows SET delivery_status='delivered', delivered_at=now(),
4149
+ lease_until=NULL, last_error_code=NULL WHERE id=$1`,
4150
+ [rowId]
4151
+ );
4152
+ await client.query(
4153
+ `UPDATE analytics_crm_imports i SET status=CASE
4154
+ WHEN EXISTS(SELECT 1 FROM analytics_crm_import_rows r WHERE r.import_id=i.id AND r.delivery_status='pending') THEN 'processing'
4155
+ WHEN EXISTS(SELECT 1 FROM analytics_crm_import_rows r WHERE r.import_id=i.id AND r.delivery_status IN ('failed','expired')) THEN 'partial'
4156
+ ELSE 'complete' END,
4157
+ completed_at=CASE WHEN EXISTS(SELECT 1 FROM analytics_crm_import_rows r WHERE r.import_id=i.id AND r.delivery_status='pending') THEN NULL ELSE now() END
4158
+ WHERE i.id=$1`,
4159
+ [importId]
4160
+ );
4161
+ await client.query("COMMIT");
4162
+ } catch (error) {
4163
+ await client.query("ROLLBACK");
4164
+ throw error;
4165
+ } finally {
4166
+ client.release();
4167
+ }
4168
+ }
4169
+ async function deferAnalyticsCrmImportRow(rowId, importId, attempts, errorCode) {
4170
+ const terminal = attempts >= 8;
4171
+ const delayMinutes = Math.min(5 * 2 ** Math.max(0, attempts - 1), 360);
4172
+ await getAnalyticsPool().query(
4173
+ `UPDATE analytics_crm_import_rows SET
4174
+ delivery_status=CASE WHEN expires_at<=now() THEN 'expired' WHEN $2 THEN 'failed' ELSE 'pending' END,
4175
+ lease_until=NULL, last_error_code=$3,
4176
+ next_attempt_at=now()+($4::text || ' minutes')::interval WHERE id=$1`,
4177
+ [rowId, terminal, errorCode.slice(0, 80), String(delayMinutes)]
4178
+ );
4179
+ if (terminal) {
4180
+ await getAnalyticsPool().query(
4181
+ `UPDATE analytics_crm_imports SET status='partial' WHERE id=$1`,
4182
+ [importId]
4183
+ );
4184
+ }
4185
+ }
2515
4186
  async function createAnalyticsActivationDestination(input) {
4187
+ assertSafeAnalyticsEventMapping(input.eventMapping);
4188
+ const externalDatasetId = assertProviderDestination(input.platform, input.externalDatasetId);
4189
+ const operatingAccountId = input.operatingAccountId?.trim();
4190
+ if (input.platform === "google" && !operatingAccountId) {
4191
+ throw new AnalyticsRepositoryError(
4192
+ "analytics_provider_destination_required",
4193
+ "A Google Ads operating account ID is required for Data Manager activation.",
4194
+ 400
4195
+ );
4196
+ }
4197
+ assertConnectionSupportsProvider(input.validatedConnection, input.platform);
4198
+ if (input.validatedConnection.id !== input.connectionRef) {
4199
+ throw new AnalyticsRepositoryError(
4200
+ "analytics_provider_action_unavailable",
4201
+ "The selected connection cannot send conversions for this provider.",
4202
+ 409
4203
+ );
4204
+ }
2516
4205
  const db = getAnalyticsPool();
2517
4206
  await requireEditor(db, input.siteId, input.userId);
2518
4207
  const result = await db.query(
2519
- `INSERT INTO analytics_activation_destinations(id, site_id, platform, name, connection_ref, external_dataset_id, event_mapping, created_by_user_id)
2520
- VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8) RETURNING *`,
4208
+ `INSERT INTO analytics_activation_destinations(id, site_id, platform, name, connection_ref, external_dataset_id, event_mapping, provider_config, created_by_user_id)
4209
+ VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9) RETURNING *`,
2521
4210
  [
2522
- randomUUID(),
4211
+ randomUUID2(),
2523
4212
  input.siteId,
2524
4213
  input.platform,
2525
4214
  input.name.trim(),
2526
- input.connectionRef?.trim() || null,
2527
- input.externalDatasetId?.trim() || null,
4215
+ input.connectionRef.trim(),
4216
+ externalDatasetId,
2528
4217
  JSON.stringify(input.eventMapping ?? {}),
4218
+ JSON.stringify(operatingAccountId ? { operatingAccountId } : {}),
2529
4219
  input.userId
2530
4220
  ]
2531
4221
  );
@@ -2559,10 +4249,342 @@ async function archiveAnalyticsActivationDestination(input) {
2559
4249
  404
2560
4250
  );
2561
4251
  }
4252
+ function safeActivationErrorCategory(error) {
4253
+ const message = error instanceof Error ? error.message.toLowerCase() : "";
4254
+ if (/401|403|authori[sz]/.test(message)) return "activation_authorization_failed";
4255
+ if (/429|rate.?limit/.test(message)) return "activation_rate_limited";
4256
+ if (/invalid|required|schema|validation/.test(message)) return "activation_payload_invalid";
4257
+ if (/timeout|timed out|temporar|unavailable|\b5\d\d\b/.test(message)) return "activation_provider_unavailable";
4258
+ return "activation_provider_test_failed";
4259
+ }
4260
+ function boundedActivationWarnings(receipt) {
4261
+ return receipt.warnings.slice(0, 50).map((warning) => warning.slice(0, 500));
4262
+ }
4263
+ async function loadActivationDestinationForEditor(input) {
4264
+ const db = getAnalyticsPool();
4265
+ await requireEditor(db, input.siteId, input.userId);
4266
+ const result = await db.query(
4267
+ `SELECT id, site_id, platform, connection_ref, external_dataset_id,
4268
+ event_mapping, provider_config, mapping_version, readiness
4269
+ FROM analytics_activation_destinations
4270
+ WHERE id=$1 AND site_id=$2 AND status='active'`,
4271
+ [input.destinationId, input.siteId]
4272
+ );
4273
+ const destination = result.rows[0];
4274
+ if (!destination) {
4275
+ throw new AnalyticsRepositoryError(
4276
+ "analytics_activation_destination_not_found",
4277
+ "Activation destination not found.",
4278
+ 404
4279
+ );
4280
+ }
4281
+ return destination;
4282
+ }
4283
+ async function getAnalyticsActivationDestinationConnectionRef(input) {
4284
+ const destination = await loadActivationDestinationForEditor(input);
4285
+ if (!destination.connection_ref) {
4286
+ throw new AnalyticsRepositoryError(
4287
+ "analytics_provider_action_unavailable",
4288
+ "This destination has no connected provider account.",
4289
+ 409
4290
+ );
4291
+ }
4292
+ return destination.connection_ref;
4293
+ }
4294
+ async function setAnalyticsActivationReadiness(input) {
4295
+ const allowed = input.evidenceKind === "provider_test" && input.nextReadiness === "verified" || input.evidenceKind === "real_delivery" && input.nextReadiness === "live" || input.evidenceKind === "provider_diagnostic" && input.nextReadiness === "degraded";
4296
+ if (!allowed || input.expectedReadiness.length === 0) {
4297
+ throw new AnalyticsRepositoryError(
4298
+ "analytics_activation_readiness_evidence_required",
4299
+ "Provider evidence is required for this activation readiness transition.",
4300
+ 409
4301
+ );
4302
+ }
4303
+ const evidencePredicate = input.evidenceKind === "provider_test" ? `EXISTS (SELECT 1 FROM analytics_activation_destination_tests t
4304
+ WHERE t.id=$5 AND t.destination_id=d.id AND t.accepted=true)` : input.evidenceKind === "real_delivery" ? `EXISTS (SELECT 1 FROM analytics_activation_delivery_attempts a
4305
+ WHERE a.id=$5 AND a.destination_id=d.id AND a.accepted=true)` : `EXISTS (SELECT 1 FROM analytics_activation_destination_tests t
4306
+ WHERE t.id=$5 AND t.destination_id=d.id AND t.safe_error_category IS NOT NULL)`;
4307
+ const result = await getAnalyticsPool().query(
4308
+ `UPDATE analytics_activation_destinations d
4309
+ SET readiness=$3, updated_at=now(),
4310
+ last_tested_at=CASE WHEN $3='verified' THEN now() ELSE last_tested_at END,
4311
+ last_receipt_id=CASE WHEN $3 IN ('verified','live') THEN $5 ELSE last_receipt_id END
4312
+ WHERE d.id=$1 AND d.site_id=$2 AND d.readiness = ANY($4::text[])
4313
+ AND ${evidencePredicate}
4314
+ RETURNING d.readiness`,
4315
+ [input.destinationId, input.siteId, input.nextReadiness, [...input.expectedReadiness], input.evidenceId]
4316
+ );
4317
+ const readiness = result.rows[0]?.readiness;
4318
+ if (!readiness) {
4319
+ throw new AnalyticsRepositoryError(
4320
+ "analytics_activation_readiness_conflict",
4321
+ "Activation readiness changed or the required provider evidence is unavailable.",
4322
+ 409
4323
+ );
4324
+ }
4325
+ return readiness;
4326
+ }
4327
+ async function testAnalyticsActivationDestination(input) {
4328
+ const destination = await loadActivationDestinationForEditor(input);
4329
+ if (!destination.connection_ref || !destination.external_dataset_id) {
4330
+ throw new AnalyticsRepositoryError(
4331
+ "analytics_provider_destination_required",
4332
+ "This destination is missing its provider connection or destination identifier.",
4333
+ 409
4334
+ );
4335
+ }
4336
+ if (destination.connection_ref !== input.validatedConnection.id) {
4337
+ throw new AnalyticsRepositoryError(
4338
+ "analytics_provider_action_unavailable",
4339
+ "The selected connection cannot test this destination.",
4340
+ 409
4341
+ );
4342
+ }
4343
+ const definition = assertConnectionSupportsProvider(input.validatedConnection, destination.platform);
4344
+ if ((destination.platform === "meta" || destination.platform === "tiktok") && !input.testEventCode) {
4345
+ throw new AnalyticsRepositoryError(
4346
+ "analytics_activation_provider_test_code_required",
4347
+ `A ${destination.platform} provider test event code is required; X-Ray will not send a synthetic test as a live event.`,
4348
+ 400
4349
+ );
4350
+ }
4351
+ if (destination.platform === "reddit" && !input.testId) {
4352
+ throw new AnalyticsRepositoryError(
4353
+ "analytics_activation_provider_test_code_required",
4354
+ "A Reddit CAPI test ID is required; X-Ray will not send a synthetic test as a live event.",
4355
+ 400
4356
+ );
4357
+ }
4358
+ const testId = randomUUID2();
4359
+ const syntheticHash = createHash("sha256").update(`xray-provider-test:${testId}`).digest("hex");
4360
+ const providerConfig = destination.provider_config ?? {};
4361
+ const payload = {
4362
+ eventId: `xray-test:${testId}`,
4363
+ eventName: "xray_destination_test",
4364
+ eventTime: (/* @__PURE__ */ new Date()).toISOString(),
4365
+ actionSource: "system_generated",
4366
+ match: { emailSha256: syntheticHash },
4367
+ operatingAccountId: providerConfig.operatingAccountId,
4368
+ ...input.testEventCode ? { testEventCode: input.testEventCode } : {},
4369
+ ...input.testId ? { testId: input.testId } : {}
4370
+ };
4371
+ const request = buildProviderTestRequest({
4372
+ platform: destination.platform,
4373
+ destinationId: destination.external_dataset_id,
4374
+ eventMapping: destination.event_mapping,
4375
+ payload
4376
+ });
4377
+ await getAnalyticsPool().query(
4378
+ `INSERT INTO analytics_activation_destination_tests(
4379
+ id,destination_id,mapping_version,actor
4380
+ ) VALUES ($1,$2,$3,$4)`,
4381
+ [testId, destination.id, destination.mapping_version, input.actor.slice(0, 240)]
4382
+ );
4383
+ try {
4384
+ const raw = await input.runAction({
4385
+ connectionRef: destination.connection_ref,
4386
+ actionName: definition.actionName,
4387
+ idempotencyKey: `xray-destination-test:${testId}`,
4388
+ payload: request
4389
+ });
4390
+ const receipt = normalizeProviderReceipt(destination.platform, raw, payload.eventId);
4391
+ const rawRecord = raw && typeof raw === "object" ? raw : {};
4392
+ const validationOnly = rawRecord.validationOnly === true || rawRecord.schemaValidationOnly === true;
4393
+ const accepted = receipt.accepted || destination.platform === "google" && validationOnly;
4394
+ await getAnalyticsPool().query(
4395
+ `UPDATE analytics_activation_destination_tests
4396
+ SET provider_request_id=$2, provider_event_id=$3, warnings=$4::jsonb,
4397
+ response_received_at=now(), accepted=$5, validation_only=$6,
4398
+ next_diagnostic_at=CASE WHEN $7 THEN now()+interval '2 minutes' ELSE NULL END
4399
+ WHERE id=$1 AND response_received_at IS NULL`,
4400
+ [
4401
+ testId,
4402
+ receipt.requestId ?? null,
4403
+ receipt.eventId ?? null,
4404
+ JSON.stringify(boundedActivationWarnings(receipt)),
4405
+ accepted,
4406
+ validationOnly,
4407
+ Boolean(definition.supportsDiagnostics && receipt.requestId && !validationOnly)
4408
+ ]
4409
+ );
4410
+ let readiness = destination.readiness;
4411
+ if (accepted && destination.readiness !== "live") {
4412
+ readiness = await setAnalyticsActivationReadiness({
4413
+ siteId: input.siteId,
4414
+ destinationId: destination.id,
4415
+ expectedReadiness: ["configured_unverified", "degraded", "verified"],
4416
+ nextReadiness: "verified",
4417
+ evidenceKind: "provider_test",
4418
+ evidenceId: testId
4419
+ });
4420
+ }
4421
+ return {
4422
+ testId,
4423
+ accepted,
4424
+ validationOnly,
4425
+ readiness,
4426
+ providerRequestId: receipt.requestId,
4427
+ providerEventId: receipt.eventId,
4428
+ warnings: boundedActivationWarnings(receipt)
4429
+ };
4430
+ } catch (error) {
4431
+ if (error instanceof AnalyticsRepositoryError && error.code.startsWith("analytics_activation_readiness_")) {
4432
+ throw error;
4433
+ }
4434
+ const category = safeActivationErrorCategory(error);
4435
+ await getAnalyticsPool().query(
4436
+ `UPDATE analytics_activation_destination_tests
4437
+ SET safe_error_category=$2, response_received_at=now()
4438
+ WHERE id=$1 AND response_received_at IS NULL`,
4439
+ [testId, category]
4440
+ );
4441
+ throw new AnalyticsRepositoryError(category, "The provider did not accept the destination test.", 409);
4442
+ }
4443
+ }
4444
+ async function listAnalyticsActivationReceipts(input) {
4445
+ const db = getAnalyticsPool();
4446
+ await accessFor(db, input.siteId, input.userId);
4447
+ const limit = Math.min(Math.max(input.limit ?? 50, 1), 100);
4448
+ const result = await db.query(
4449
+ `SELECT * FROM (
4450
+ SELECT t.id, 'test'::text AS kind, t.mapping_version, t.provider_request_id,
4451
+ t.provider_event_id, t.warnings, t.safe_error_category,
4452
+ t.request_started_at, t.response_received_at, t.next_diagnostic_at,
4453
+ t.actor, t.accepted, t.validation_only, t.created_at,
4454
+ NULL::uuid AS job_id, false AS retryable
4455
+ FROM analytics_activation_destination_tests t
4456
+ JOIN analytics_activation_destinations d ON d.id=t.destination_id
4457
+ WHERE t.destination_id=$1 AND d.site_id=$2
4458
+ UNION ALL
4459
+ SELECT a.id, 'delivery'::text AS kind, a.mapping_version, a.provider_request_id,
4460
+ a.provider_event_id, a.warnings, a.safe_error_category,
4461
+ a.request_started_at, a.response_received_at, a.next_diagnostic_at,
4462
+ a.actor, a.accepted, false AS validation_only, a.created_at,
4463
+ CASE WHEN j.status IN ('failed','expired')
4464
+ AND d.status='active'
4465
+ AND d.readiness IN ('verified','live','degraded')
4466
+ THEN j.id ELSE NULL END AS job_id,
4467
+ (j.status IN ('failed','expired')
4468
+ AND d.status='active'
4469
+ AND d.readiness IN ('verified','live','degraded')) AS retryable
4470
+ FROM analytics_activation_delivery_attempts a
4471
+ JOIN analytics_activation_destinations d ON d.id=a.destination_id
4472
+ JOIN analytics_activation_jobs j ON j.id=a.job_id
4473
+ WHERE a.destination_id=$1 AND d.site_id=$2
4474
+ ) receipts ORDER BY created_at DESC LIMIT $3`,
4475
+ [input.destinationId, input.siteId, limit]
4476
+ );
4477
+ return result.rows.map((row) => {
4478
+ const record = row;
4479
+ if (record.kind === "delivery" && record.retryable === true && typeof record.job_id === "string") {
4480
+ return record;
4481
+ }
4482
+ const safeReceipt = { ...record };
4483
+ delete safeReceipt.job_id;
4484
+ return { ...safeReceipt, retryable: false };
4485
+ });
4486
+ }
4487
+ async function retryAnalyticsActivationJob(input) {
4488
+ const db = getAnalyticsPool();
4489
+ await requireEditor(db, input.siteId, input.userId);
4490
+ const result = await db.query(
4491
+ `UPDATE analytics_activation_jobs j
4492
+ SET status='pending', next_attempt_at=now(), lease_until=NULL,
4493
+ dead_lettered_at=NULL, last_error_code=NULL, response_category=NULL
4494
+ FROM analytics_activation_destinations d
4495
+ WHERE j.id=$1 AND d.id=j.destination_id AND d.site_id=$2
4496
+ AND d.status='active' AND d.readiness IN ('verified','live','degraded')
4497
+ AND j.status IN ('failed','expired')
4498
+ RETURNING j.id, j.status, j.attempts, j.next_attempt_at`,
4499
+ [input.jobId, input.siteId]
4500
+ );
4501
+ const job = result.rows[0];
4502
+ if (!job) {
4503
+ throw new AnalyticsRepositoryError(
4504
+ "analytics_activation_retry_conflict",
4505
+ "Only a failed delivery for an active verified destination can be retried.",
4506
+ 409
4507
+ );
4508
+ }
4509
+ return job;
4510
+ }
4511
+ function safeDiagnosticSummary(raw) {
4512
+ const record = raw && typeof raw === "object" ? raw : {};
4513
+ const destinations = Array.isArray(record.destinations) ? record.destinations.filter((entry) => Boolean(entry && typeof entry === "object")) : [];
4514
+ const warnings = destinations.flatMap((entry) => {
4515
+ const warningRows = Array.isArray(entry.warnings) ? entry.warnings : [];
4516
+ return warningRows.map((warning) => {
4517
+ const row = warning && typeof warning === "object" ? warning : {};
4518
+ return `${String(row.reason ?? "provider_warning").slice(0, 200)}: ${String(row.count ?? 0).slice(0, 20)}`;
4519
+ });
4520
+ }).slice(0, 50);
4521
+ const hasErrors = destinations.some((entry) => Array.isArray(entry.errors) && entry.errors.length > 0);
4522
+ const pending = destinations.some((entry) => /pending|processing|received/i.test(String(entry.status ?? "")));
4523
+ return { warnings, ...hasErrors ? { errorCategory: "activation_provider_diagnostic_rejected" } : {}, pending };
4524
+ }
4525
+ async function pollAnalyticsActivationDiagnostics(input) {
4526
+ const destination = await loadActivationDestinationForEditor(input);
4527
+ const definition = assertConnectionSupportsProvider(input.validatedConnection, destination.platform);
4528
+ if (!definition.supportsDiagnostics || !definition.diagnosticsActionName || !destination.connection_ref) {
4529
+ throw new AnalyticsRepositoryError(
4530
+ "analytics_activation_diagnostics_unavailable",
4531
+ "Diagnostics are not available for this activation provider.",
4532
+ 409
4533
+ );
4534
+ }
4535
+ if (destination.connection_ref !== input.validatedConnection.id) {
4536
+ throw new AnalyticsRepositoryError(
4537
+ "analytics_provider_action_unavailable",
4538
+ "The selected connection cannot read diagnostics for this destination.",
4539
+ 409
4540
+ );
4541
+ }
4542
+ const testResult = await getAnalyticsPool().query(
4543
+ `SELECT id, provider_request_id FROM analytics_activation_destination_tests
4544
+ WHERE destination_id=$1 AND provider_request_id IS NOT NULL
4545
+ ORDER BY created_at DESC LIMIT 1`,
4546
+ [destination.id]
4547
+ );
4548
+ const test = testResult.rows[0];
4549
+ if (!test) {
4550
+ throw new AnalyticsRepositoryError(
4551
+ "analytics_activation_diagnostics_unavailable",
4552
+ "No provider request is available for diagnostics.",
4553
+ 409
4554
+ );
4555
+ }
4556
+ const raw = await input.runAction({
4557
+ connectionRef: destination.connection_ref,
4558
+ actionName: definition.diagnosticsActionName,
4559
+ idempotencyKey: `xray-diagnostics:${test.id}`,
4560
+ payload: { requestId: test.provider_request_id }
4561
+ });
4562
+ const summary = safeDiagnosticSummary(raw);
4563
+ await getAnalyticsPool().query(
4564
+ `UPDATE analytics_activation_destination_tests
4565
+ SET warnings=$2::jsonb, safe_error_category=$3,
4566
+ next_diagnostic_at=CASE WHEN $4 THEN now()+interval '5 minutes' ELSE NULL END
4567
+ WHERE id=$1`,
4568
+ [test.id, JSON.stringify(summary.warnings), summary.errorCategory ?? null, summary.pending]
4569
+ );
4570
+ let readiness = destination.readiness;
4571
+ if (summary.errorCategory && ["verified", "live"].includes(destination.readiness)) {
4572
+ readiness = await setAnalyticsActivationReadiness({
4573
+ siteId: input.siteId,
4574
+ destinationId: destination.id,
4575
+ expectedReadiness: [destination.readiness],
4576
+ nextReadiness: "degraded",
4577
+ evidenceKind: "provider_diagnostic",
4578
+ evidenceId: test.id
4579
+ });
4580
+ }
4581
+ return { testId: test.id, readiness, ...summary };
4582
+ }
2562
4583
  async function queueAnalyticsActivation(input) {
2563
4584
  const db = getAnalyticsPool();
2564
4585
  const destinations = await db.query(
2565
- `SELECT id FROM analytics_activation_destinations WHERE site_id = $1 AND status = 'active'`,
4586
+ `SELECT id FROM analytics_activation_destinations
4587
+ WHERE site_id = $1 AND status = 'active' AND readiness IN ('verified','live')`,
2566
4588
  [input.siteId]
2567
4589
  );
2568
4590
  let queued = 0;
@@ -2571,7 +4593,7 @@ async function queueAnalyticsActivation(input) {
2571
4593
  `INSERT INTO analytics_activation_jobs(id, destination_id, conversion_id, person_id, payload_ciphertext)
2572
4594
  VALUES ($1,$2,$3,$4,$5) ON CONFLICT(destination_id, conversion_id) DO NOTHING`,
2573
4595
  [
2574
- randomUUID(),
4596
+ randomUUID2(),
2575
4597
  destination.id,
2576
4598
  input.conversionId,
2577
4599
  input.personId ?? null,
@@ -2583,7 +4605,7 @@ async function queueAnalyticsActivation(input) {
2583
4605
  return queued;
2584
4606
  }
2585
4607
  async function queueAnalyticsFormDelivery(input) {
2586
- const id = randomUUID();
4608
+ const id = randomUUID2();
2587
4609
  await getAnalyticsPool().query(
2588
4610
  `INSERT INTO analytics_form_delivery_jobs(id, submission_id, owner_user_id, payload_ciphertext, last_error_code)
2589
4611
  VALUES ($1,$2,$3,$4,$5)`,
@@ -2597,6 +4619,36 @@ async function queueAnalyticsFormDelivery(input) {
2597
4619
  );
2598
4620
  return id;
2599
4621
  }
4622
+ async function sweepAnalyticsRestrictedRetention() {
4623
+ const db = getAnalyticsPool();
4624
+ const client = await db.connect();
4625
+ try {
4626
+ await client.query("BEGIN");
4627
+ const restricted = await client.query(
4628
+ `DELETE FROM analytics_restricted_data WHERE expires_at <= now()`
4629
+ );
4630
+ const webhooks = await client.query(
4631
+ `UPDATE analytics_webhook_receipts SET payload_ciphertext=NULL
4632
+ WHERE payload_ciphertext IS NOT NULL AND payload_expires_at <= now()`
4633
+ );
4634
+ const imports = await client.query(
4635
+ `UPDATE analytics_crm_import_rows SET delivery_status='expired', lease_until=NULL,
4636
+ last_error_code='retention_expired'
4637
+ WHERE delivery_status='pending' AND expires_at <= now()`
4638
+ );
4639
+ await client.query("COMMIT");
4640
+ return {
4641
+ restrictedDeleted: restricted.rowCount ?? 0,
4642
+ webhookPayloadsCleared: webhooks.rowCount ?? 0,
4643
+ expiredImportRows: imports.rowCount ?? 0
4644
+ };
4645
+ } catch (error) {
4646
+ await client.query("ROLLBACK");
4647
+ throw error;
4648
+ } finally {
4649
+ client.release();
4650
+ }
4651
+ }
2600
4652
  async function claimAnalyticsFormDeliveryJobs(limit = 10) {
2601
4653
  const result = await getAnalyticsPool().query(
2602
4654
  `WITH due AS (
@@ -2710,7 +4762,7 @@ async function analyticsHealth(siteId, userId) {
2710
4762
  }
2711
4763
  async function refreshAnalyticsDailyRollups(input) {
2712
4764
  const db = getAnalyticsPool();
2713
- const runId = randomUUID();
4765
+ const runId = randomUUID2();
2714
4766
  await db.query(
2715
4767
  `INSERT INTO analytics_rollup_runs(id, window_start, window_end, status) VALUES ($1, $2, $3, 'running')`,
2716
4768
  [runId, input.start, input.end]
@@ -2907,7 +4959,7 @@ async function createAnalyticsExport(input) {
2907
4959
  `Generated ${(/* @__PURE__ */ new Date()).toISOString()} from the governed ${input.report} report contract.`
2908
4960
  ].join("\n");
2909
4961
  }
2910
- const id = randomUUID();
4962
+ const id = randomUUID2();
2911
4963
  const inserted = await getAnalyticsPool().query(
2912
4964
  `INSERT INTO analytics_exports(id, site_id, requested_by_user_id, idempotency_key, report, format, filters, content)
2913
4965
  VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8)
@@ -2945,11 +4997,30 @@ export {
2945
4997
  getSessionSecret,
2946
4998
  signSession,
2947
4999
  verifySession,
5000
+ ensureServiceConnectionsSchema,
5001
+ reconcileDiscoveredNangoConnections,
5002
+ listServiceConnections,
5003
+ getOwnedServiceConnection,
5004
+ updateServiceConnectionTools,
5005
+ setServiceConnectionProviderIdentity,
5006
+ setServiceConnectionUserLabel,
5007
+ recordServiceConnectionHealth,
5008
+ setServiceConnectionActions,
5009
+ markServiceConnectionDisconnected,
5010
+ recordServiceConnectionAction,
5011
+ claimServiceConnectionAction,
5012
+ AnalyticsProviderRegistryError,
5013
+ getAnalyticsProviderDefinition,
5014
+ assertConnectionSupportsProvider,
5015
+ buildProviderDeliveryRequest,
5016
+ normalizeProviderReceipt,
2948
5017
  AnalyticsRepositoryError,
2949
5018
  getAnalyticsPool,
2950
5019
  closeAnalyticsPool,
2951
5020
  migrateAnalytics,
2952
5021
  normalizeObservedHostname,
5022
+ requireAnalyticsAccess,
5023
+ requireAnalyticsEditor,
2953
5024
  createAnalyticsSite,
2954
5025
  listAnalyticsSites,
2955
5026
  updateAnalyticsBusinessModel,
@@ -2961,6 +5032,10 @@ export {
2961
5032
  listAnalyticsPixels,
2962
5033
  updateAnalyticsPixel,
2963
5034
  setAnalyticsPixelDomainState,
5035
+ upsertAnalyticsHostGroup,
5036
+ listAnalyticsHostGroups,
5037
+ prepareAnalyticsLinkerIssue,
5038
+ redeemAnalyticsLinkerRecord,
2964
5039
  MAX_ENGAGED_MS,
2965
5040
  ENGAGED_SESSION_MS,
2966
5041
  normalizeAnalyticsPath,
@@ -2988,16 +5063,27 @@ export {
2988
5063
  getPublicAnalyticsForm,
2989
5064
  recordAnalyticsFormSubmission,
2990
5065
  sanitizeClickIds,
5066
+ identityHmac,
2991
5067
  linkAnalyticsFormIdentity,
2992
5068
  listAnalyticsPeople,
2993
5069
  getAnalyticsPersonJourney,
2994
5070
  createAnalyticsCrmImport,
2995
5071
  listAnalyticsCrmImports,
5072
+ claimAnalyticsCrmImportRows,
5073
+ completeAnalyticsCrmImportRow,
5074
+ deferAnalyticsCrmImportRow,
2996
5075
  createAnalyticsActivationDestination,
2997
5076
  listAnalyticsActivationDestinations,
2998
5077
  archiveAnalyticsActivationDestination,
5078
+ getAnalyticsActivationDestinationConnectionRef,
5079
+ setAnalyticsActivationReadiness,
5080
+ testAnalyticsActivationDestination,
5081
+ listAnalyticsActivationReceipts,
5082
+ retryAnalyticsActivationJob,
5083
+ pollAnalyticsActivationDiagnostics,
2999
5084
  queueAnalyticsActivation,
3000
5085
  queueAnalyticsFormDelivery,
5086
+ sweepAnalyticsRestrictedRetention,
3001
5087
  claimAnalyticsFormDeliveryJobs,
3002
5088
  completeAnalyticsFormDelivery,
3003
5089
  deferAnalyticsFormDelivery,