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.
- package/CHANGELOG.md +33 -1
- package/README.md +2 -2
- package/dist/{analytics-repository-RUXBFSFX.js → analytics-repository-DJCV6HCX.js} +38 -3
- package/dist/bin/api-server.cjs +12041 -7009
- package/dist/bin/api-server.js +3 -3
- package/dist/bin/mcp-scraper-cli.cjs +1 -1
- package/dist/bin/mcp-scraper-cli.js +1 -1
- package/dist/bin/mcp-scraper-install.cjs +3 -3
- package/dist/bin/mcp-scraper-install.js +2 -2
- package/dist/bin/mcp-stdio-server.cjs +299 -15
- package/dist/bin/mcp-stdio-server.js +4 -4
- package/dist/{chunk-CNLLTD5B.js → chunk-SZEDXVJH.js} +2169 -83
- package/dist/{chunk-GT63KEUB.js → chunk-TV76V5OO.js} +1 -1
- package/dist/{chunk-XOOKZYZG.js → chunk-V6H7NGSV.js} +298 -14
- package/dist/{chunk-AYJHI2FK.js → chunk-WNL42AMC.js} +2 -2
- package/dist/{chunk-IHDTONZV.js → chunk-XKWTKQ7Y.js} +2 -2
- package/dist/{chunk-LCGTNWOT.js → chunk-YUGC5K35.js} +1 -1
- package/dist/{extract-bundle-QQIYJSBW.js → extract-bundle-TBETDXWP.js} +2 -2
- package/dist/{server-MFVYRB6I.js → server-PWHUIHZO.js} +9617 -7001
- package/dist/{site-extract-repository-7EP6UX6I.js → site-extract-repository-I5PHDLPQ.js} +2 -2
- package/dist/{worker-JGWV2LID.js → worker-HUEIDH6F.js} +1 -1
- package/package.json +2 -1
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
[
|
|
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_${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
1232
|
-
|
|
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
|
-
|
|
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
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
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
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
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
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
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 =
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
[
|
|
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
|
-
|
|
4211
|
+
randomUUID2(),
|
|
2523
4212
|
input.siteId,
|
|
2524
4213
|
input.platform,
|
|
2525
4214
|
input.name.trim(),
|
|
2526
|
-
input.connectionRef
|
|
2527
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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,
|