mcp-scraper 0.72.7 → 0.73.1
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 +35 -1
- package/README.md +16 -2
- package/dist/{analytics-repository-6YLRSWYO.js → analytics-repository-7XYPQ2YJ.js} +15 -1
- package/dist/bin/api-server.js +3 -3
- package/dist/bin/mcp-scraper-cli.js +1 -1
- package/dist/bin/mcp-scraper-install.js +2 -2
- package/dist/bin/mcp-stdio-server.js +4 -4
- package/dist/{chunk-FVL4GUTP.js → chunk-EQJRPDUS.js} +2 -2
- package/dist/{chunk-ZSVXNLLM.js → chunk-G75QXTBE.js} +188 -14
- package/dist/{chunk-SR7GSLEA.js → chunk-M6XDENOO.js} +953 -122
- package/dist/{chunk-RRZAYUEX.js → chunk-NLPFY2IF.js} +1 -1
- package/dist/{chunk-RAFQEPJ4.js → chunk-Q2J23DHY.js} +1 -1
- package/dist/{chunk-I45JV4EU.js → chunk-RV2D27R4.js} +1 -1
- package/dist/{chunk-K2SXQGKM.js → chunk-S45DGELW.js} +1 -1
- package/dist/{extract-bundle-337ZDIIQ.js → extract-bundle-2V22S7EU.js} +2 -2
- package/dist/{gmail-service-QXDGNWUJ.js → gmail-service-JHL4PQFN.js} +2 -2
- package/dist/{server-M5FLUWYD.js → server-663CRTGY.js} +14433 -12846
- package/dist/{site-extract-repository-ACNAVNDN.js → site-extract-repository-DUAEAU6A.js} +2 -2
- package/dist/{worker-JQORPCFQ.js → worker-CBK5DWEE.js} +1 -1
- package/package.json +16 -4
- package/dist/bin/api-server.cjs +0 -108656
- package/dist/bin/api-server.d.cts +0 -1
- package/dist/bin/api-server.d.ts +0 -1
- package/dist/bin/mcp-scraper-cli.cjs +0 -3219
- package/dist/bin/mcp-scraper-cli.d.cts +0 -1
- package/dist/bin/mcp-scraper-cli.d.ts +0 -1
- package/dist/bin/mcp-scraper-install.cjs +0 -134
- package/dist/bin/mcp-scraper-install.d.cts +0 -1
- package/dist/bin/mcp-scraper-install.d.ts +0 -1
- package/dist/bin/mcp-stdio-server.cjs +0 -22333
- package/dist/bin/mcp-stdio-server.d.cts +0 -1
- package/dist/bin/mcp-stdio-server.d.ts +0 -1
- package/dist/bin/paa-harvest.cjs +0 -4745
- package/dist/bin/paa-harvest.d.cts +0 -1
- package/dist/bin/paa-harvest.d.ts +0 -1
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
} from "./chunk-PJEEKOUM.js";
|
|
5
5
|
|
|
6
6
|
// src/api/analytics-repository.ts
|
|
7
|
-
import { createHash, createHmac, randomBytes, randomUUID } from "crypto";
|
|
7
|
+
import { createHash as createHash2, createHmac as createHmac2, randomBytes as randomBytes2, randomUUID as randomUUID2 } from "crypto";
|
|
8
8
|
import { Pool } from "pg";
|
|
9
9
|
|
|
10
10
|
// src/api/analytics-attribution.ts
|
|
@@ -82,6 +82,261 @@ function resolveAnalyticsAttribution(input) {
|
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
// src/api/analytics-identity-profile.ts
|
|
86
|
+
import { createHash, randomUUID } from "crypto";
|
|
87
|
+
|
|
88
|
+
// src/api/analytics-crypto.ts
|
|
89
|
+
import {
|
|
90
|
+
createCipheriv,
|
|
91
|
+
createDecipheriv,
|
|
92
|
+
createHmac,
|
|
93
|
+
randomBytes,
|
|
94
|
+
scryptSync,
|
|
95
|
+
timingSafeEqual
|
|
96
|
+
} from "crypto";
|
|
97
|
+
var VERSION = "xray1";
|
|
98
|
+
function rootSecret() {
|
|
99
|
+
return process.env.ANALYTICS_RESTRICTED_DATA_SECRET?.trim() || getSessionSecret();
|
|
100
|
+
}
|
|
101
|
+
function keyFor(purpose) {
|
|
102
|
+
return scryptSync(rootSecret(), `xray-restricted:${purpose}:v1`, 32);
|
|
103
|
+
}
|
|
104
|
+
function encryptAnalyticsRestricted(plaintext, purpose) {
|
|
105
|
+
const normalizedPurpose = purpose.trim().slice(0, 80);
|
|
106
|
+
if (!normalizedPurpose) throw new Error("analytics encryption purpose required");
|
|
107
|
+
const iv = randomBytes(12);
|
|
108
|
+
const cipher = createCipheriv("aes-256-gcm", keyFor(normalizedPurpose), iv);
|
|
109
|
+
cipher.setAAD(Buffer.from(`${VERSION}:${normalizedPurpose}`, "utf8"));
|
|
110
|
+
const ciphertext = Buffer.concat([
|
|
111
|
+
cipher.update(plaintext, "utf8"),
|
|
112
|
+
cipher.final()
|
|
113
|
+
]);
|
|
114
|
+
const tag = cipher.getAuthTag();
|
|
115
|
+
return [
|
|
116
|
+
VERSION,
|
|
117
|
+
Buffer.from(normalizedPurpose, "utf8").toString("base64url"),
|
|
118
|
+
iv.toString("base64url"),
|
|
119
|
+
tag.toString("base64url"),
|
|
120
|
+
ciphertext.toString("base64url")
|
|
121
|
+
].join(".");
|
|
122
|
+
}
|
|
123
|
+
function decryptAnalyticsRestricted(stored, expectedPurpose) {
|
|
124
|
+
try {
|
|
125
|
+
const [version, encodedPurpose, iv, tag, ciphertext] = stored.split(".");
|
|
126
|
+
if (version !== VERSION || !encodedPurpose || !iv || !tag || !ciphertext)
|
|
127
|
+
return null;
|
|
128
|
+
const purpose = Buffer.from(encodedPurpose, "base64url").toString("utf8");
|
|
129
|
+
if (purpose !== expectedPurpose) return null;
|
|
130
|
+
const decipher = createDecipheriv(
|
|
131
|
+
"aes-256-gcm",
|
|
132
|
+
keyFor(purpose),
|
|
133
|
+
Buffer.from(iv, "base64url")
|
|
134
|
+
);
|
|
135
|
+
decipher.setAAD(Buffer.from(`${VERSION}:${purpose}`, "utf8"));
|
|
136
|
+
decipher.setAuthTag(Buffer.from(tag, "base64url"));
|
|
137
|
+
return Buffer.concat([
|
|
138
|
+
decipher.update(Buffer.from(ciphertext, "base64url")),
|
|
139
|
+
decipher.final()
|
|
140
|
+
]).toString("utf8");
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function analyticsIdentityHmac(kind, value) {
|
|
146
|
+
return createHmac("sha256", keyFor(`identity:${kind}`)).update(value.trim().toLowerCase()).digest("hex");
|
|
147
|
+
}
|
|
148
|
+
function signAnalyticsWebhook(provider, timestamp, rawBody, secret) {
|
|
149
|
+
return createHmac("sha256", secret).update(`${provider}.${timestamp}.${rawBody}`).digest("hex");
|
|
150
|
+
}
|
|
151
|
+
function verifyAnalyticsWebhookSignature(actual, expected) {
|
|
152
|
+
if (!actual || actual.length !== expected.length) return false;
|
|
153
|
+
try {
|
|
154
|
+
return timingSafeEqual(Buffer.from(actual), Buffer.from(expected));
|
|
155
|
+
} catch {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// src/api/analytics-identity-profile.ts
|
|
161
|
+
var ANALYTICS_IDENTITY_PROFILE_PURPOSE = "enhanced_matching";
|
|
162
|
+
var ANALYTICS_IDENTITY_PROFILE_RETENTION_DAYS = 90;
|
|
163
|
+
var ANALYTICS_IDENTITY_PROFILE_RATE_LIMIT = 10;
|
|
164
|
+
var AnalyticsIdentityProfileError = class extends Error {
|
|
165
|
+
constructor(code, message, status) {
|
|
166
|
+
super(message);
|
|
167
|
+
this.code = code;
|
|
168
|
+
this.status = status;
|
|
169
|
+
this.name = "AnalyticsIdentityProfileError";
|
|
170
|
+
}
|
|
171
|
+
code;
|
|
172
|
+
status;
|
|
173
|
+
};
|
|
174
|
+
function normalizedText(value, max) {
|
|
175
|
+
const normalized = value?.trim().replace(/\s+/g, " ").slice(0, max);
|
|
176
|
+
return normalized || null;
|
|
177
|
+
}
|
|
178
|
+
function maskedEmail(value) {
|
|
179
|
+
if (!value) return null;
|
|
180
|
+
const [local, domain] = value.split("@");
|
|
181
|
+
if (!local || !domain) return null;
|
|
182
|
+
return `${local.slice(0, 1)}${"*".repeat(Math.min(6, Math.max(2, local.length - 1)))}@${domain}`;
|
|
183
|
+
}
|
|
184
|
+
function maskedPhone(value) {
|
|
185
|
+
if (!value) return null;
|
|
186
|
+
const digits = value.replace(/\D/g, "");
|
|
187
|
+
if (digits.length < 4) return null;
|
|
188
|
+
return `***-***-${digits.slice(-4)}`;
|
|
189
|
+
}
|
|
190
|
+
function auditSubject(reference) {
|
|
191
|
+
return createHash("sha256").update(reference).digest("hex");
|
|
192
|
+
}
|
|
193
|
+
async function assertAnalyticsIdentitySignalsNotTombstoned(db, input) {
|
|
194
|
+
if (!input.signals.length) return;
|
|
195
|
+
const kinds = input.signals.map((signal) => signal.kind);
|
|
196
|
+
const hashes = input.signals.map((signal) => signal.valueHmac);
|
|
197
|
+
const result = await db.query(
|
|
198
|
+
`SELECT 1 FROM analytics_identity_tombstones
|
|
199
|
+
WHERE site_id=$1 AND (kind,value_hmac) IN (
|
|
200
|
+
SELECT * FROM unnest($2::text[],$3::text[])
|
|
201
|
+
) LIMIT 1`,
|
|
202
|
+
[input.siteId, kinds, hashes]
|
|
203
|
+
);
|
|
204
|
+
if (result.rowCount) {
|
|
205
|
+
throw new AnalyticsIdentityProfileError(
|
|
206
|
+
"analytics_identity_deleted",
|
|
207
|
+
"This identity cannot be automatically recreated.",
|
|
208
|
+
410
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
async function writeAnalyticsConfirmedIdentityProfile(db, input) {
|
|
213
|
+
const displayName = normalizedText(input.displayName, 160);
|
|
214
|
+
const email = normalizedText(input.email, 320)?.toLowerCase() ?? null;
|
|
215
|
+
const phone = normalizedText(input.phone, 40);
|
|
216
|
+
if (!displayName && !email && !phone) return false;
|
|
217
|
+
const person = await db.query(
|
|
218
|
+
`SELECT p.public_ref FROM analytics_people p
|
|
219
|
+
JOIN analytics_identity_namespaces n ON n.id=p.namespace_id AND n.site_id=p.site_id AND n.status='approved'
|
|
220
|
+
WHERE p.id=$1 AND p.site_id=$2`,
|
|
221
|
+
[input.personId, input.siteId]
|
|
222
|
+
);
|
|
223
|
+
const publicRef = person.rows[0]?.public_ref;
|
|
224
|
+
if (!publicRef) return false;
|
|
225
|
+
const refs = [...new Set([publicRef, ...input.consentSubjectRefs].filter(Boolean))];
|
|
226
|
+
const consent = await db.query(
|
|
227
|
+
`SELECT s.latest_receipt_id::text FROM analytics_consent_current_state s
|
|
228
|
+
JOIN analytics_consent_receipts r ON r.id=s.latest_receipt_id AND r.site_id=s.site_id
|
|
229
|
+
WHERE s.site_id=$1 AND s.subject_ref=ANY($2::text[])
|
|
230
|
+
AND s.site_analytics=true AND s.enhanced_matching=true AND s.gpc=false
|
|
231
|
+
AND r.action NOT IN ('withdraw','gpc')
|
|
232
|
+
AND (s.expires_at IS NULL OR s.expires_at > now())
|
|
233
|
+
ORDER BY s.effective_at DESC LIMIT 1`,
|
|
234
|
+
[input.siteId, refs]
|
|
235
|
+
);
|
|
236
|
+
const consentReceiptId = consent.rows[0]?.latest_receipt_id;
|
|
237
|
+
if (!consentReceiptId) return false;
|
|
238
|
+
const verifiedAt = input.verifiedAt && Number.isFinite(Date.parse(input.verifiedAt)) ? new Date(input.verifiedAt).toISOString() : (/* @__PURE__ */ new Date()).toISOString();
|
|
239
|
+
const ciphertext = encryptAnalyticsRestricted(JSON.stringify({
|
|
240
|
+
schemaVersion: 1,
|
|
241
|
+
displayName,
|
|
242
|
+
email,
|
|
243
|
+
phone,
|
|
244
|
+
evidenceType: input.evidenceType,
|
|
245
|
+
verifiedAt
|
|
246
|
+
}), "identity-profile");
|
|
247
|
+
await db.query(
|
|
248
|
+
`INSERT INTO analytics_restricted_data(id,site_id,person_id,purpose,subject_ref,payload_ciphertext,expires_at,consent_receipt_id)
|
|
249
|
+
VALUES($1,$2,$3,$4,$5,$6,now()+($7::int * interval '1 day'),$8)
|
|
250
|
+
ON CONFLICT(site_id,purpose,subject_ref) DO UPDATE SET
|
|
251
|
+
person_id=EXCLUDED.person_id,payload_ciphertext=EXCLUDED.payload_ciphertext,
|
|
252
|
+
expires_at=EXCLUDED.expires_at,consent_receipt_id=EXCLUDED.consent_receipt_id,created_at=now()`,
|
|
253
|
+
[
|
|
254
|
+
randomUUID(),
|
|
255
|
+
input.siteId,
|
|
256
|
+
input.personId,
|
|
257
|
+
ANALYTICS_IDENTITY_PROFILE_PURPOSE,
|
|
258
|
+
publicRef,
|
|
259
|
+
ciphertext,
|
|
260
|
+
ANALYTICS_IDENTITY_PROFILE_RETENTION_DAYS,
|
|
261
|
+
consentReceiptId
|
|
262
|
+
]
|
|
263
|
+
);
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
async function resolveAnalyticsMaskedIdentityProfile(db, input) {
|
|
267
|
+
const now = input.now ?? /* @__PURE__ */ new Date();
|
|
268
|
+
const role = await db.query(
|
|
269
|
+
"SELECT role FROM analytics_site_grants WHERE site_id=$1 AND user_id=$2",
|
|
270
|
+
[input.siteId, input.actorUserId]
|
|
271
|
+
);
|
|
272
|
+
if (!role.rowCount) throw new AnalyticsIdentityProfileError("analytics_site_not_found", "Site not found.", 404);
|
|
273
|
+
if (!["owner", "editor"].includes(role.rows[0].role))
|
|
274
|
+
throw new AnalyticsIdentityProfileError("analytics_forbidden", "Editor access is required.", 403);
|
|
275
|
+
const recent = await db.query(
|
|
276
|
+
`SELECT count(*)::text AS count FROM analytics_identity_profile_access_audits
|
|
277
|
+
WHERE site_id=$1 AND actor_user_id=$2 AND created_at > $3::timestamptz - interval '1 minute'`,
|
|
278
|
+
[input.siteId, input.actorUserId, now.toISOString()]
|
|
279
|
+
);
|
|
280
|
+
if (Number(recent.rows[0]?.count ?? 0) >= ANALYTICS_IDENTITY_PROFILE_RATE_LIMIT) {
|
|
281
|
+
await db.query(
|
|
282
|
+
`INSERT INTO analytics_identity_profile_access_audits(id,site_id,actor_user_id,subject_ref_hmac,purpose,result,created_at)
|
|
283
|
+
VALUES($1,$2,$3,$4,'journey_identity_display','rate_limited',$5)`,
|
|
284
|
+
[randomUUID(), input.siteId, input.actorUserId, auditSubject(input.requestedPersonRef), now.toISOString()]
|
|
285
|
+
);
|
|
286
|
+
throw new AnalyticsIdentityProfileError("analytics_identity_profile_rate_limited", "Identity profile rate limit exceeded.", 429);
|
|
287
|
+
}
|
|
288
|
+
const unavailable = async (result) => {
|
|
289
|
+
await db.query(
|
|
290
|
+
`INSERT INTO analytics_identity_profile_access_audits(id,site_id,actor_user_id,subject_ref_hmac,purpose,result,created_at)
|
|
291
|
+
VALUES($1,$2,$3,$4,'journey_identity_display',$5,$6)`,
|
|
292
|
+
[randomUUID(), input.siteId, input.actorUserId, auditSubject(input.requestedPersonRef), result, now.toISOString()]
|
|
293
|
+
);
|
|
294
|
+
throw new AnalyticsIdentityProfileError("analytics_identity_profile_not_found", "Identity profile not found.", 404);
|
|
295
|
+
};
|
|
296
|
+
if (!input.personId) return unavailable("not_found");
|
|
297
|
+
const row = await db.query(
|
|
298
|
+
`SELECT p.public_ref,r.payload_ciphertext,r.expires_at
|
|
299
|
+
FROM analytics_people p
|
|
300
|
+
JOIN analytics_restricted_data r ON r.person_id=p.id AND r.site_id=p.site_id
|
|
301
|
+
AND r.purpose=$3 AND r.subject_ref=p.public_ref AND r.expires_at>$4
|
|
302
|
+
JOIN analytics_consent_current_state s ON s.site_id=p.site_id AND s.latest_receipt_id=r.consent_receipt_id
|
|
303
|
+
AND s.site_analytics=true AND s.enhanced_matching=true AND s.gpc=false
|
|
304
|
+
AND (s.expires_at IS NULL OR s.expires_at>$4)
|
|
305
|
+
JOIN analytics_consent_receipts c ON c.id=s.latest_receipt_id AND c.site_id=s.site_id
|
|
306
|
+
AND c.action NOT IN ('withdraw','gpc')
|
|
307
|
+
WHERE p.id=$1 AND p.site_id=$2
|
|
308
|
+
AND NOT EXISTS (SELECT 1 FROM analytics_identity_tombstones t WHERE t.site_id=p.site_id AND t.person_id=p.id)
|
|
309
|
+
LIMIT 1`,
|
|
310
|
+
[input.personId, input.siteId, ANALYTICS_IDENTITY_PROFILE_PURPOSE, now.toISOString()]
|
|
311
|
+
);
|
|
312
|
+
if (!row.rowCount) return unavailable("not_found");
|
|
313
|
+
const plaintext = decryptAnalyticsRestricted(row.rows[0].payload_ciphertext, "identity-profile");
|
|
314
|
+
if (!plaintext) return unavailable("unavailable");
|
|
315
|
+
let parsed;
|
|
316
|
+
try {
|
|
317
|
+
parsed = JSON.parse(plaintext);
|
|
318
|
+
} catch {
|
|
319
|
+
return unavailable("unavailable");
|
|
320
|
+
}
|
|
321
|
+
const evidenceType = parsed.evidenceType;
|
|
322
|
+
if (!["first_party_form", "authenticated_user", "crm_contact"].includes(String(evidenceType)))
|
|
323
|
+
return unavailable("unavailable");
|
|
324
|
+
const profile = {
|
|
325
|
+
personRef: input.requestedPersonRef,
|
|
326
|
+
displayName: normalizedText(typeof parsed.displayName === "string" ? parsed.displayName : void 0, 160),
|
|
327
|
+
maskedEmail: maskedEmail(typeof parsed.email === "string" ? parsed.email : null),
|
|
328
|
+
maskedPhone: maskedPhone(typeof parsed.phone === "string" ? parsed.phone : null),
|
|
329
|
+
evidenceType,
|
|
330
|
+
verifiedAt: typeof parsed.verifiedAt === "string" && Number.isFinite(Date.parse(parsed.verifiedAt)) ? new Date(parsed.verifiedAt).toISOString() : now.toISOString()
|
|
331
|
+
};
|
|
332
|
+
await db.query(
|
|
333
|
+
`INSERT INTO analytics_identity_profile_access_audits(id,site_id,person_id,actor_user_id,subject_ref_hmac,purpose,result,created_at)
|
|
334
|
+
VALUES($1,$2,$3,$4,$5,'journey_identity_display','returned_masked',$6)`,
|
|
335
|
+
[randomUUID(), input.siteId, input.personId, input.actorUserId, auditSubject(input.requestedPersonRef), now.toISOString()]
|
|
336
|
+
);
|
|
337
|
+
return profile;
|
|
338
|
+
}
|
|
339
|
+
|
|
85
340
|
// src/api/analytics-provider-registry.ts
|
|
86
341
|
var META_ACTION_SOURCES = /* @__PURE__ */ new Set([
|
|
87
342
|
"email",
|
|
@@ -605,7 +860,6 @@ async function closeAnalyticsPool() {
|
|
|
605
860
|
async function migrateAnalytics() {
|
|
606
861
|
const db = getAnalyticsPool();
|
|
607
862
|
const client = await db.connect();
|
|
608
|
-
await client.query("BEGIN");
|
|
609
863
|
try {
|
|
610
864
|
await client.query(`
|
|
611
865
|
CREATE TABLE IF NOT EXISTS analytics_schema_migrations (
|
|
@@ -1289,6 +1543,33 @@ async function migrateAnalytics() {
|
|
|
1289
1543
|
UNIQUE(site_id, purpose, subject_ref)
|
|
1290
1544
|
)
|
|
1291
1545
|
`);
|
|
1546
|
+
await client.query(`
|
|
1547
|
+
CREATE TABLE IF NOT EXISTS analytics_identity_tombstones (
|
|
1548
|
+
id uuid PRIMARY KEY,
|
|
1549
|
+
site_id uuid NOT NULL REFERENCES analytics_sites(id) ON DELETE CASCADE,
|
|
1550
|
+
person_id uuid,
|
|
1551
|
+
kind text NOT NULL,
|
|
1552
|
+
value_hmac text NOT NULL,
|
|
1553
|
+
reason text NOT NULL CHECK (reason IN ('subject_deletion')),
|
|
1554
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
1555
|
+
UNIQUE(site_id,kind,value_hmac)
|
|
1556
|
+
)
|
|
1557
|
+
`);
|
|
1558
|
+
await client.query(`
|
|
1559
|
+
CREATE TABLE IF NOT EXISTS analytics_identity_profile_access_audits (
|
|
1560
|
+
id uuid PRIMARY KEY,
|
|
1561
|
+
site_id uuid NOT NULL REFERENCES analytics_sites(id) ON DELETE CASCADE,
|
|
1562
|
+
person_id uuid,
|
|
1563
|
+
actor_user_id bigint NOT NULL,
|
|
1564
|
+
subject_ref_hmac text NOT NULL,
|
|
1565
|
+
purpose text NOT NULL CHECK (purpose IN ('journey_identity_display')),
|
|
1566
|
+
result text NOT NULL CHECK (result IN ('returned_masked','not_found','unavailable','rate_limited')),
|
|
1567
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
1568
|
+
)
|
|
1569
|
+
`);
|
|
1570
|
+
await client.query(
|
|
1571
|
+
"CREATE INDEX IF NOT EXISTS analytics_identity_profile_access_rate ON analytics_identity_profile_access_audits(site_id,actor_user_id,created_at DESC)"
|
|
1572
|
+
);
|
|
1292
1573
|
await client.query(
|
|
1293
1574
|
`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'))`
|
|
1294
1575
|
);
|
|
@@ -1390,6 +1671,11 @@ async function migrateAnalytics() {
|
|
|
1390
1671
|
await client.query(
|
|
1391
1672
|
`ALTER TABLE analytics_people ADD COLUMN IF NOT EXISTS public_ref text`
|
|
1392
1673
|
);
|
|
1674
|
+
await client.query(
|
|
1675
|
+
`UPDATE analytics_people
|
|
1676
|
+
SET public_ref = 'xrp_' || substr(md5(random()::text || clock_timestamp()::text || id::text || site_id::text), 1, 24)
|
|
1677
|
+
WHERE public_ref IS NULL`
|
|
1678
|
+
);
|
|
1393
1679
|
await client.query(
|
|
1394
1680
|
`ALTER TABLE analytics_events ADD COLUMN IF NOT EXISTS event_definition_id uuid REFERENCES analytics_event_definitions(id) ON DELETE SET NULL`
|
|
1395
1681
|
);
|
|
@@ -1554,6 +1840,9 @@ async function migrateAnalytics() {
|
|
|
1554
1840
|
PRIMARY KEY(site_id, subject_ref)
|
|
1555
1841
|
)
|
|
1556
1842
|
`);
|
|
1843
|
+
await client.query(
|
|
1844
|
+
"ALTER TABLE analytics_restricted_data ADD COLUMN IF NOT EXISTS consent_receipt_id uuid REFERENCES analytics_consent_receipts(id) ON DELETE CASCADE"
|
|
1845
|
+
);
|
|
1557
1846
|
await client.query(`
|
|
1558
1847
|
CREATE TABLE IF NOT EXISTS analytics_identity_namespaces (
|
|
1559
1848
|
id uuid PRIMARY KEY,
|
|
@@ -1586,13 +1875,19 @@ async function migrateAnalytics() {
|
|
|
1586
1875
|
)
|
|
1587
1876
|
`);
|
|
1588
1877
|
await client.query(
|
|
1589
|
-
`ALTER TABLE analytics_people ADD COLUMN IF NOT EXISTS namespace_id uuid
|
|
1878
|
+
`ALTER TABLE analytics_people ADD COLUMN IF NOT EXISTS namespace_id uuid`
|
|
1879
|
+
);
|
|
1880
|
+
await client.query(
|
|
1881
|
+
`ALTER TABLE analytics_identity_nodes ADD COLUMN IF NOT EXISTS namespace_id uuid`
|
|
1590
1882
|
);
|
|
1591
1883
|
await client.query(
|
|
1592
|
-
`ALTER TABLE
|
|
1884
|
+
`ALTER TABLE analytics_identity_edges ADD COLUMN IF NOT EXISTS namespace_id uuid`
|
|
1593
1885
|
);
|
|
1594
1886
|
await client.query(
|
|
1595
|
-
|
|
1887
|
+
"CREATE UNIQUE INDEX IF NOT EXISTS analytics_people_site_ref ON analytics_people(id, site_id)"
|
|
1888
|
+
);
|
|
1889
|
+
await client.query(
|
|
1890
|
+
"CREATE UNIQUE INDEX IF NOT EXISTS analytics_identity_nodes_site_ref ON analytics_identity_nodes(id, site_id)"
|
|
1596
1891
|
);
|
|
1597
1892
|
await client.query(
|
|
1598
1893
|
"CREATE UNIQUE INDEX IF NOT EXISTS analytics_identity_nodes_namespace_ref ON analytics_identity_nodes(id, site_id, namespace_id)"
|
|
@@ -1600,6 +1895,62 @@ async function migrateAnalytics() {
|
|
|
1600
1895
|
await client.query(
|
|
1601
1896
|
"CREATE UNIQUE INDEX IF NOT EXISTS analytics_people_namespace_ref ON analytics_people(id, site_id, namespace_id)"
|
|
1602
1897
|
);
|
|
1898
|
+
await client.query(`
|
|
1899
|
+
DO $$
|
|
1900
|
+
BEGIN
|
|
1901
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='analytics_people_namespace_site_fkey') THEN
|
|
1902
|
+
ALTER TABLE analytics_people ADD CONSTRAINT analytics_people_namespace_site_fkey
|
|
1903
|
+
FOREIGN KEY(namespace_id, site_id) REFERENCES analytics_identity_namespaces(id, site_id)
|
|
1904
|
+
ON DELETE RESTRICT NOT VALID;
|
|
1905
|
+
END IF;
|
|
1906
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='analytics_identity_nodes_namespace_site_fkey') THEN
|
|
1907
|
+
ALTER TABLE analytics_identity_nodes ADD CONSTRAINT analytics_identity_nodes_namespace_site_fkey
|
|
1908
|
+
FOREIGN KEY(namespace_id, site_id) REFERENCES analytics_identity_namespaces(id, site_id)
|
|
1909
|
+
ON DELETE RESTRICT NOT VALID;
|
|
1910
|
+
END IF;
|
|
1911
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='analytics_identity_edges_person_site_fkey') THEN
|
|
1912
|
+
ALTER TABLE analytics_identity_edges ADD CONSTRAINT analytics_identity_edges_person_site_fkey
|
|
1913
|
+
FOREIGN KEY(person_id, site_id) REFERENCES analytics_people(id, site_id)
|
|
1914
|
+
ON DELETE CASCADE NOT VALID;
|
|
1915
|
+
END IF;
|
|
1916
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='analytics_identity_edges_node_site_fkey') THEN
|
|
1917
|
+
ALTER TABLE analytics_identity_edges ADD CONSTRAINT analytics_identity_edges_node_site_fkey
|
|
1918
|
+
FOREIGN KEY(identity_node_id, site_id) REFERENCES analytics_identity_nodes(id, site_id)
|
|
1919
|
+
ON DELETE CASCADE NOT VALID;
|
|
1920
|
+
END IF;
|
|
1921
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='analytics_identity_edges_namespace_site_fkey') THEN
|
|
1922
|
+
ALTER TABLE analytics_identity_edges ADD CONSTRAINT analytics_identity_edges_namespace_site_fkey
|
|
1923
|
+
FOREIGN KEY(namespace_id, site_id) REFERENCES analytics_identity_namespaces(id, site_id)
|
|
1924
|
+
ON DELETE RESTRICT NOT VALID;
|
|
1925
|
+
END IF;
|
|
1926
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='analytics_identity_edges_person_namespace_fkey') THEN
|
|
1927
|
+
ALTER TABLE analytics_identity_edges ADD CONSTRAINT analytics_identity_edges_person_namespace_fkey
|
|
1928
|
+
FOREIGN KEY(person_id, site_id, namespace_id) REFERENCES analytics_people(id, site_id, namespace_id)
|
|
1929
|
+
ON DELETE CASCADE NOT VALID;
|
|
1930
|
+
END IF;
|
|
1931
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='analytics_identity_edges_node_namespace_fkey') THEN
|
|
1932
|
+
ALTER TABLE analytics_identity_edges ADD CONSTRAINT analytics_identity_edges_node_namespace_fkey
|
|
1933
|
+
FOREIGN KEY(identity_node_id, site_id, namespace_id) REFERENCES analytics_identity_nodes(id, site_id, namespace_id)
|
|
1934
|
+
ON DELETE CASCADE NOT VALID;
|
|
1935
|
+
END IF;
|
|
1936
|
+
END $$
|
|
1937
|
+
`);
|
|
1938
|
+
for (const constraint of [
|
|
1939
|
+
["analytics_people", "analytics_people_namespace_site_fkey"],
|
|
1940
|
+
["analytics_identity_nodes", "analytics_identity_nodes_namespace_site_fkey"],
|
|
1941
|
+
["analytics_identity_edges", "analytics_identity_edges_person_site_fkey"],
|
|
1942
|
+
["analytics_identity_edges", "analytics_identity_edges_node_site_fkey"],
|
|
1943
|
+
["analytics_identity_edges", "analytics_identity_edges_namespace_site_fkey"],
|
|
1944
|
+
["analytics_identity_edges", "analytics_identity_edges_person_namespace_fkey"],
|
|
1945
|
+
["analytics_identity_edges", "analytics_identity_edges_node_namespace_fkey"]
|
|
1946
|
+
]) {
|
|
1947
|
+
await client.query(`ALTER TABLE ${constraint[0]} VALIDATE CONSTRAINT ${constraint[1]}`);
|
|
1948
|
+
}
|
|
1949
|
+
await client.query("ALTER TABLE analytics_people DROP CONSTRAINT IF EXISTS analytics_people_namespace_id_fkey");
|
|
1950
|
+
await client.query("ALTER TABLE analytics_identity_nodes DROP CONSTRAINT IF EXISTS analytics_identity_nodes_namespace_id_fkey");
|
|
1951
|
+
await client.query("ALTER TABLE analytics_identity_edges DROP CONSTRAINT IF EXISTS analytics_identity_edges_namespace_id_fkey");
|
|
1952
|
+
await client.query("ALTER TABLE analytics_identity_edges DROP CONSTRAINT IF EXISTS analytics_identity_edges_person_id_fkey");
|
|
1953
|
+
await client.query("ALTER TABLE analytics_identity_edges DROP CONSTRAINT IF EXISTS analytics_identity_edges_identity_node_id_fkey");
|
|
1603
1954
|
await client.query(`
|
|
1604
1955
|
CREATE TABLE IF NOT EXISTS analytics_candidate_associations (
|
|
1605
1956
|
id uuid PRIMARY KEY,
|
|
@@ -2255,9 +2606,7 @@ async function migrateAnalytics() {
|
|
|
2255
2606
|
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'), ('2026-08-27.1')
|
|
2256
2607
|
ON CONFLICT (version) DO NOTHING
|
|
2257
2608
|
`);
|
|
2258
|
-
await client.query("COMMIT");
|
|
2259
2609
|
} catch (error) {
|
|
2260
|
-
await client.query("ROLLBACK");
|
|
2261
2610
|
throw error;
|
|
2262
2611
|
} finally {
|
|
2263
2612
|
client.release();
|
|
@@ -2265,7 +2614,7 @@ async function migrateAnalytics() {
|
|
|
2265
2614
|
}
|
|
2266
2615
|
function normalizeSlug(value) {
|
|
2267
2616
|
const slug = value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80);
|
|
2268
|
-
return slug || `site-${
|
|
2617
|
+
return slug || `site-${randomBytes2(4).toString("hex")}`;
|
|
2269
2618
|
}
|
|
2270
2619
|
function normalizeObservedHostname(origin) {
|
|
2271
2620
|
if (!origin || origin === "null") return null;
|
|
@@ -2278,7 +2627,7 @@ function normalizeObservedHostname(origin) {
|
|
|
2278
2627
|
}
|
|
2279
2628
|
}
|
|
2280
2629
|
function publicPixelId() {
|
|
2281
|
-
return `px_${
|
|
2630
|
+
return `px_${randomBytes2(18).toString("base64url")}`;
|
|
2282
2631
|
}
|
|
2283
2632
|
function mapRows(rows) {
|
|
2284
2633
|
return rows;
|
|
@@ -2316,7 +2665,7 @@ async function requireAnalyticsEditor(client, siteId, userId) {
|
|
|
2316
2665
|
async function createAnalyticsSite(input) {
|
|
2317
2666
|
const db = getAnalyticsPool();
|
|
2318
2667
|
const client = await db.connect();
|
|
2319
|
-
const id =
|
|
2668
|
+
const id = randomUUID2();
|
|
2320
2669
|
const baseSlug = normalizeSlug(input.slug || input.name);
|
|
2321
2670
|
try {
|
|
2322
2671
|
await client.query("BEGIN");
|
|
@@ -2332,7 +2681,7 @@ async function createAnalyticsSite(input) {
|
|
|
2332
2681
|
} catch (error) {
|
|
2333
2682
|
const code = error.code;
|
|
2334
2683
|
if (code !== "23505" || attempt === 2) throw error;
|
|
2335
|
-
slug = `${baseSlug}-${
|
|
2684
|
+
slug = `${baseSlug}-${randomBytes2(2).toString("hex")}`;
|
|
2336
2685
|
}
|
|
2337
2686
|
}
|
|
2338
2687
|
await client.query(
|
|
@@ -2547,7 +2896,7 @@ async function createAnalyticsPixel(input) {
|
|
|
2547
2896
|
VALUES ($1, $2, $3, $4, $5)
|
|
2548
2897
|
RETURNING id, site_id, public_id, name, environment, status, created_at::text, NULL::text AS last_event_at`,
|
|
2549
2898
|
[
|
|
2550
|
-
|
|
2899
|
+
randomUUID2(),
|
|
2551
2900
|
input.siteId,
|
|
2552
2901
|
publicPixelId(),
|
|
2553
2902
|
input.name.trim(),
|
|
@@ -2624,7 +2973,7 @@ async function setAnalyticsPixelDomainState(input) {
|
|
|
2624
2973
|
SELECT $1, p.id, $4, $5 FROM analytics_pixels p WHERE p.id = $2 AND p.site_id = $3
|
|
2625
2974
|
ON CONFLICT(pixel_id, hostname) DO UPDATE SET state = EXCLUDED.state, updated_at = now()
|
|
2626
2975
|
RETURNING id`,
|
|
2627
|
-
[
|
|
2976
|
+
[randomUUID2(), input.pixelId, input.siteId, hostname, input.state]
|
|
2628
2977
|
);
|
|
2629
2978
|
if (!result.rowCount)
|
|
2630
2979
|
throw new AnalyticsRepositoryError(
|
|
@@ -2666,7 +3015,7 @@ async function upsertAnalyticsHostGroup(input) {
|
|
|
2666
3015
|
ON CONFLICT(pixel_id, name) DO UPDATE SET
|
|
2667
3016
|
auto_decorate=EXCLUDED.auto_decorate, updated_at=now()
|
|
2668
3017
|
RETURNING id`,
|
|
2669
|
-
[
|
|
3018
|
+
[randomUUID2(), input.siteId, input.pixelId, input.name.trim().slice(0, 120), Boolean(input.autoDecorate)]
|
|
2670
3019
|
);
|
|
2671
3020
|
await client.query("DELETE FROM analytics_host_group_members WHERE group_id=$1", [group.rows[0].id]);
|
|
2672
3021
|
for (const hostname of hostnames)
|
|
@@ -2749,7 +3098,7 @@ async function prepareAnalyticsLinkerIssue(input) {
|
|
|
2749
3098
|
VALUES ($1,$2,'visitor_id',$3)
|
|
2750
3099
|
ON CONFLICT(site_id, kind, value_hmac) DO UPDATE SET last_seen_at=now()
|
|
2751
3100
|
RETURNING id`,
|
|
2752
|
-
[
|
|
3101
|
+
[randomUUID2(), siteId, identityHmac("visitor_id", input.visitorId)]
|
|
2753
3102
|
);
|
|
2754
3103
|
const existingPerson = await client.query(
|
|
2755
3104
|
`SELECT person_id FROM analytics_identity_edges
|
|
@@ -2760,19 +3109,20 @@ async function prepareAnalyticsLinkerIssue(input) {
|
|
|
2760
3109
|
let personId = existingPerson.rows[0]?.person_id;
|
|
2761
3110
|
if (!personId) {
|
|
2762
3111
|
const person = await client.query(
|
|
2763
|
-
`INSERT INTO analytics_people(id, site_id, crm_person_ref) VALUES ($1,$2,$3)
|
|
2764
|
-
ON CONFLICT(site_id, crm_person_ref) DO UPDATE SET last_seen_at=now()
|
|
2765
|
-
|
|
3112
|
+
`INSERT INTO analytics_people(id, site_id, crm_person_ref, public_ref) VALUES ($1,$2,$3,$4)
|
|
3113
|
+
ON CONFLICT(site_id, crm_person_ref) DO UPDATE SET last_seen_at=now(),
|
|
3114
|
+
public_ref=COALESCE(analytics_people.public_ref,EXCLUDED.public_ref) RETURNING id`,
|
|
3115
|
+
[randomUUID2(), siteId, `XRay::anonymous::${identityHmac("visitor_id", input.visitorId).slice(0, 24)}`, newAnalyticsPersonPublicRef()]
|
|
2766
3116
|
);
|
|
2767
3117
|
personId = person.rows[0].id;
|
|
2768
3118
|
await client.query(
|
|
2769
3119
|
`INSERT INTO analytics_identity_edges(id, site_id, person_id, identity_node_id, evidence_kind, confidence)
|
|
2770
3120
|
VALUES ($1,$2,$3,$4,'first_party_linker',0.980)
|
|
2771
3121
|
ON CONFLICT(person_id, identity_node_id, evidence_kind) DO UPDATE SET last_seen_at=now()`,
|
|
2772
|
-
[
|
|
3122
|
+
[randomUUID2(), siteId, personId, node.rows[0].id]
|
|
2773
3123
|
);
|
|
2774
3124
|
}
|
|
2775
|
-
const tokenId =
|
|
3125
|
+
const tokenId = randomUUID2();
|
|
2776
3126
|
await client.query(
|
|
2777
3127
|
`INSERT INTO analytics_linker_tokens(
|
|
2778
3128
|
id, site_id, pixel_id, host_group_id, identity_node_id,
|
|
@@ -2828,13 +3178,13 @@ async function redeemAnalyticsLinkerRecord(input) {
|
|
|
2828
3178
|
VALUES ($1,$2,'visitor_id',$3)
|
|
2829
3179
|
ON CONFLICT(site_id, kind, value_hmac) DO UPDATE SET last_seen_at=now()
|
|
2830
3180
|
RETURNING id`,
|
|
2831
|
-
[
|
|
3181
|
+
[randomUUID2(), input.siteId, identityHmac("visitor_id", input.visitorId)]
|
|
2832
3182
|
);
|
|
2833
3183
|
await client.query(
|
|
2834
3184
|
`INSERT INTO analytics_identity_edges(id, site_id, person_id, identity_node_id, evidence_kind, confidence)
|
|
2835
3185
|
VALUES ($1,$2,$3,$4,'cross_domain_linker',0.980)
|
|
2836
3186
|
ON CONFLICT(person_id, identity_node_id, evidence_kind) DO UPDATE SET last_seen_at=now()`,
|
|
2837
|
-
[
|
|
3187
|
+
[randomUUID2(), input.siteId, personId, targetNode.rows[0].id]
|
|
2838
3188
|
);
|
|
2839
3189
|
await client.query(
|
|
2840
3190
|
"UPDATE analytics_events SET person_id=$1 WHERE site_id=$2 AND visitor_id=$3 AND person_id IS NULL",
|
|
@@ -2858,6 +3208,133 @@ async function redeemAnalyticsLinkerRecord(input) {
|
|
|
2858
3208
|
client.release();
|
|
2859
3209
|
}
|
|
2860
3210
|
}
|
|
3211
|
+
function analyticsIdentityResolutionAllowed(event) {
|
|
3212
|
+
return Boolean(
|
|
3213
|
+
event.consent?.analytics === "granted" && (event.consent.purposes?.enhanced_matching === "granted" || event.consent.adUserData === "granted") && event.consent.globalPrivacyControl !== true
|
|
3214
|
+
);
|
|
3215
|
+
}
|
|
3216
|
+
function analyticsIdentityPromotionAllowed(event) {
|
|
3217
|
+
return Boolean(event.identity && analyticsIdentityResolutionAllowed(event));
|
|
3218
|
+
}
|
|
3219
|
+
async function projectAnalyticsPixelEventConsent(client, input) {
|
|
3220
|
+
const visitorId = input.event.visitorId?.trim().slice(0, 240);
|
|
3221
|
+
const consent = input.event.consent;
|
|
3222
|
+
if (!visitorId || !consent) return null;
|
|
3223
|
+
const occurredAtMs = Date.parse(input.event.occurredAt);
|
|
3224
|
+
const serverNow = Date.now();
|
|
3225
|
+
if (!Number.isFinite(occurredAtMs) || occurredAtMs > serverNow + 5 * 6e4 || occurredAtMs < serverNow - 24 * 60 * 6e4)
|
|
3226
|
+
return null;
|
|
3227
|
+
const explicit = consent.purposes;
|
|
3228
|
+
const purposes = {
|
|
3229
|
+
siteAnalytics: explicit ? explicit.site_analytics === "granted" : consent.analytics === "granted",
|
|
3230
|
+
enhancedMatching: explicit ? explicit.enhanced_matching === "granted" : consent.adUserData === "granted",
|
|
3231
|
+
advertisingMeasurement: explicit ? explicit.advertising_measurement === "granted" : consent.adPersonalization === "granted"
|
|
3232
|
+
};
|
|
3233
|
+
const gpc = consent.globalPrivacyControl === true;
|
|
3234
|
+
if (gpc) {
|
|
3235
|
+
purposes.siteAnalytics = false;
|
|
3236
|
+
purposes.enhancedMatching = false;
|
|
3237
|
+
purposes.advertisingMeasurement = false;
|
|
3238
|
+
}
|
|
3239
|
+
const action = gpc ? "gpc" : Object.values(purposes).every(Boolean) ? "accept_all" : Object.values(purposes).every((value) => !value) ? "reject_all" : "save_choices";
|
|
3240
|
+
const occurredAt = new Date(occurredAtMs).toISOString();
|
|
3241
|
+
const previous = await client.query(
|
|
3242
|
+
`SELECT s.latest_receipt_id::text,s.site_analytics,s.enhanced_matching,s.advertising_measurement,s.gpc,r.action
|
|
3243
|
+
FROM analytics_consent_current_state s JOIN analytics_consent_receipts r ON r.id=s.latest_receipt_id
|
|
3244
|
+
WHERE s.site_id=$1 AND s.subject_ref=$2 FOR UPDATE`,
|
|
3245
|
+
[input.siteId, visitorId]
|
|
3246
|
+
);
|
|
3247
|
+
const prior = previous.rows[0];
|
|
3248
|
+
if (prior && prior.site_analytics === purposes.siteAnalytics && prior.enhanced_matching === purposes.enhancedMatching && prior.advertising_measurement === purposes.advertisingMeasurement && prior.gpc === gpc && prior.action === action) return prior.latest_receipt_id;
|
|
3249
|
+
const receiptId = randomUUID2();
|
|
3250
|
+
await client.query(
|
|
3251
|
+
`INSERT INTO analytics_consent_receipts(id,site_id,pixel_id,subject_ref,policy_version,jurisdiction_profile,
|
|
3252
|
+
necessary,site_analytics,enhanced_matching,advertising_measurement,gpc,action,source,presented_at,occurred_at,supersedes_id)
|
|
3253
|
+
VALUES($1,$2,$3,$4,1,'pixel',true,$5,$6,$7,$8,$9,'gateway',$10,$10,$11)`,
|
|
3254
|
+
[
|
|
3255
|
+
receiptId,
|
|
3256
|
+
input.siteId,
|
|
3257
|
+
input.pixelId,
|
|
3258
|
+
visitorId,
|
|
3259
|
+
purposes.siteAnalytics,
|
|
3260
|
+
purposes.enhancedMatching,
|
|
3261
|
+
purposes.advertisingMeasurement,
|
|
3262
|
+
gpc,
|
|
3263
|
+
action,
|
|
3264
|
+
occurredAt,
|
|
3265
|
+
prior?.latest_receipt_id ?? null
|
|
3266
|
+
]
|
|
3267
|
+
);
|
|
3268
|
+
await client.query(
|
|
3269
|
+
`INSERT INTO analytics_consent_current_state(site_id,subject_ref,latest_receipt_id,policy_version,jurisdiction_profile,
|
|
3270
|
+
necessary,site_analytics,enhanced_matching,advertising_measurement,gpc,effective_at)
|
|
3271
|
+
VALUES($1,$2,$3,1,'pixel',true,$4,$5,$6,$7,$8)
|
|
3272
|
+
ON CONFLICT(site_id,subject_ref) DO UPDATE SET latest_receipt_id=EXCLUDED.latest_receipt_id,
|
|
3273
|
+
policy_version=EXCLUDED.policy_version,jurisdiction_profile=EXCLUDED.jurisdiction_profile,
|
|
3274
|
+
site_analytics=EXCLUDED.site_analytics,enhanced_matching=EXCLUDED.enhanced_matching,
|
|
3275
|
+
advertising_measurement=EXCLUDED.advertising_measurement,gpc=EXCLUDED.gpc,
|
|
3276
|
+
effective_at=EXCLUDED.effective_at,updated_at=now()
|
|
3277
|
+
WHERE EXCLUDED.effective_at>=analytics_consent_current_state.effective_at`,
|
|
3278
|
+
[
|
|
3279
|
+
input.siteId,
|
|
3280
|
+
visitorId,
|
|
3281
|
+
receiptId,
|
|
3282
|
+
purposes.siteAnalytics,
|
|
3283
|
+
purposes.enhancedMatching,
|
|
3284
|
+
purposes.advertisingMeasurement,
|
|
3285
|
+
gpc,
|
|
3286
|
+
occurredAt
|
|
3287
|
+
]
|
|
3288
|
+
);
|
|
3289
|
+
return receiptId;
|
|
3290
|
+
}
|
|
3291
|
+
async function resolveAnalyticsConfirmedActivationIdentity(db, input) {
|
|
3292
|
+
if (!input.subjectRef) return { personId: null, activationEligible: false };
|
|
3293
|
+
const result = await db.query(
|
|
3294
|
+
`SELECT p.id::text AS person_id,s.advertising_measurement
|
|
3295
|
+
FROM analytics_people p
|
|
3296
|
+
JOIN analytics_consent_current_state s ON s.site_id=p.site_id AND s.subject_ref=$2
|
|
3297
|
+
JOIN analytics_consent_receipts r ON r.id=s.latest_receipt_id AND r.site_id=s.site_id
|
|
3298
|
+
WHERE p.site_id=$1
|
|
3299
|
+
AND (($3::uuid IS NOT NULL AND p.id=$3) OR ($4::text IS NOT NULL AND EXISTS (
|
|
3300
|
+
SELECT 1 FROM analytics_events e WHERE e.site_id=p.site_id AND e.event_id=$4
|
|
3301
|
+
AND e.person_id=p.id AND e.visitor_id=$2
|
|
3302
|
+
)))
|
|
3303
|
+
AND s.site_analytics=true AND s.gpc=false AND r.action NOT IN ('withdraw','gpc')
|
|
3304
|
+
AND (s.expires_at IS NULL OR s.expires_at>now()) LIMIT 1`,
|
|
3305
|
+
[input.siteId, input.subjectRef, input.personId ?? null, input.eventId ?? null]
|
|
3306
|
+
);
|
|
3307
|
+
const row = result.rows[0];
|
|
3308
|
+
return row ? { personId: row.person_id, activationEligible: row.advertising_measurement === true } : { personId: null, activationEligible: false };
|
|
3309
|
+
}
|
|
3310
|
+
async function hasCurrentAnalyticsIdentityConsent(client, siteId, visitorId, sessionId) {
|
|
3311
|
+
if (!visitorId?.trim() || !sessionId?.trim()) return false;
|
|
3312
|
+
const result = await client.query(
|
|
3313
|
+
`SELECT 1 FROM analytics_consent_current_state s
|
|
3314
|
+
JOIN analytics_consent_receipts r ON r.id=s.latest_receipt_id AND r.site_id=s.site_id
|
|
3315
|
+
WHERE s.site_id=$1 AND s.subject_ref=$2
|
|
3316
|
+
AND s.site_analytics=true AND s.enhanced_matching=true AND s.gpc=false
|
|
3317
|
+
AND r.action NOT IN ('withdraw','gpc')
|
|
3318
|
+
AND (s.expires_at IS NULL OR s.expires_at>now())
|
|
3319
|
+
AND EXISTS (SELECT 1 FROM analytics_sessions x
|
|
3320
|
+
WHERE x.site_id=s.site_id AND x.visitor_hmac=$3 AND x.session_hmac=$4)
|
|
3321
|
+
ORDER BY s.effective_at DESC LIMIT 1`,
|
|
3322
|
+
[siteId, visitorId, identityHmac("visitor_id", visitorId), identityHmac("session_id", sessionId)]
|
|
3323
|
+
);
|
|
3324
|
+
return Boolean(result.rowCount);
|
|
3325
|
+
}
|
|
3326
|
+
async function hasCurrentPersonIdentityConsent(client, siteId, personPublicRef) {
|
|
3327
|
+
const result = await client.query(
|
|
3328
|
+
`SELECT 1 FROM analytics_consent_current_state s
|
|
3329
|
+
JOIN analytics_consent_receipts r ON r.id=s.latest_receipt_id AND r.site_id=s.site_id
|
|
3330
|
+
WHERE s.site_id=$1 AND s.subject_ref=$2
|
|
3331
|
+
AND s.site_analytics=true AND s.enhanced_matching=true AND s.gpc=false
|
|
3332
|
+
AND r.action NOT IN ('withdraw','gpc')
|
|
3333
|
+
AND (s.expires_at IS NULL OR s.expires_at>now()) LIMIT 1`,
|
|
3334
|
+
[siteId, personPublicRef]
|
|
3335
|
+
);
|
|
3336
|
+
return Boolean(result.rowCount);
|
|
3337
|
+
}
|
|
2861
3338
|
var MAX_ENGAGED_MS = 30 * 60 * 1e3;
|
|
2862
3339
|
var ENGAGED_SESSION_MS = 1e4;
|
|
2863
3340
|
function boundedEngagedMs(value) {
|
|
@@ -2926,7 +3403,7 @@ async function materializeAnalyticsSessionAndTouch(input) {
|
|
|
2926
3403
|
updated_at = now()
|
|
2927
3404
|
RETURNING id`,
|
|
2928
3405
|
[
|
|
2929
|
-
|
|
3406
|
+
randomUUID2(),
|
|
2930
3407
|
input.siteId,
|
|
2931
3408
|
input.pixelId,
|
|
2932
3409
|
visitorHmac,
|
|
@@ -2942,7 +3419,7 @@ async function materializeAnalyticsSessionAndTouch(input) {
|
|
|
2942
3419
|
input.event.source || input.event.medium || input.event.campaign || input.event.referrer || Object.keys(clickIds).length
|
|
2943
3420
|
);
|
|
2944
3421
|
if (!hasTouch) return;
|
|
2945
|
-
const touchHash =
|
|
3422
|
+
const touchHash = createHash2("sha256").update(
|
|
2946
3423
|
JSON.stringify({
|
|
2947
3424
|
siteId: input.siteId,
|
|
2948
3425
|
sessionHmac,
|
|
@@ -2963,7 +3440,7 @@ async function materializeAnalyticsSessionAndTouch(input) {
|
|
|
2963
3440
|
person_id = COALESCE(analytics_attribution_touches.person_id, EXCLUDED.person_id)
|
|
2964
3441
|
RETURNING id`,
|
|
2965
3442
|
[
|
|
2966
|
-
|
|
3443
|
+
randomUUID2(),
|
|
2967
3444
|
input.siteId,
|
|
2968
3445
|
session.rows[0].id,
|
|
2969
3446
|
input.personId ?? null,
|
|
@@ -2987,7 +3464,7 @@ async function materializeAnalyticsSessionAndTouch(input) {
|
|
|
2987
3464
|
}
|
|
2988
3465
|
async function ingestAnalyticsEvents(input) {
|
|
2989
3466
|
const db = getAnalyticsPool();
|
|
2990
|
-
const requestId = `air_${
|
|
3467
|
+
const requestId = `air_${randomUUID2()}`;
|
|
2991
3468
|
const hostname = normalizeObservedHostname(input.origin);
|
|
2992
3469
|
const pixelResult = await db.query(
|
|
2993
3470
|
`SELECT p.id, p.site_id, p.status,
|
|
@@ -3008,7 +3485,7 @@ async function ingestAnalyticsEvents(input) {
|
|
|
3008
3485
|
`INSERT INTO analytics_ingestion_receipts(id, request_id, site_id, pixel_id, hostname, rejected_count, reason_codes)
|
|
3009
3486
|
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`,
|
|
3010
3487
|
[
|
|
3011
|
-
|
|
3488
|
+
randomUUID2(),
|
|
3012
3489
|
requestId,
|
|
3013
3490
|
pixel?.site_id ?? null,
|
|
3014
3491
|
pixel?.id ?? null,
|
|
@@ -3033,7 +3510,7 @@ async function ingestAnalyticsEvents(input) {
|
|
|
3033
3510
|
`INSERT INTO analytics_ingestion_receipts(id, request_id, site_id, pixel_id, hostname, rejected_count, reason_codes)
|
|
3034
3511
|
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`,
|
|
3035
3512
|
[
|
|
3036
|
-
|
|
3513
|
+
randomUUID2(),
|
|
3037
3514
|
requestId,
|
|
3038
3515
|
pixel.site_id,
|
|
3039
3516
|
pixel.id,
|
|
@@ -3058,7 +3535,7 @@ async function ingestAnalyticsEvents(input) {
|
|
|
3058
3535
|
`INSERT INTO analytics_ingestion_receipts(id, request_id, site_id, pixel_id, rejected_count, reason_codes)
|
|
3059
3536
|
VALUES ($1, $2, $3, $4, $5, $6::jsonb)`,
|
|
3060
3537
|
[
|
|
3061
|
-
|
|
3538
|
+
randomUUID2(),
|
|
3062
3539
|
requestId,
|
|
3063
3540
|
pixel.site_id,
|
|
3064
3541
|
pixel.id,
|
|
@@ -3082,7 +3559,7 @@ async function ingestAnalyticsEvents(input) {
|
|
|
3082
3559
|
ON CONFLICT(pixel_id, hostname) DO UPDATE
|
|
3083
3560
|
SET last_seen_at = now(), updated_at = now()
|
|
3084
3561
|
RETURNING state`,
|
|
3085
|
-
[
|
|
3562
|
+
[randomUUID2(), pixel.id, hostname]
|
|
3086
3563
|
);
|
|
3087
3564
|
if (domainResult.rows[0]?.state !== "approved") {
|
|
3088
3565
|
await db.query(
|
|
@@ -3096,7 +3573,7 @@ async function ingestAnalyticsEvents(input) {
|
|
|
3096
3573
|
`INSERT INTO analytics_ingestion_receipts(id, request_id, site_id, pixel_id, hostname, rejected_count, reason_codes)
|
|
3097
3574
|
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`,
|
|
3098
3575
|
[
|
|
3099
|
-
|
|
3576
|
+
randomUUID2(),
|
|
3100
3577
|
requestId,
|
|
3101
3578
|
pixel.site_id,
|
|
3102
3579
|
pixel.id,
|
|
@@ -3123,18 +3600,14 @@ async function ingestAnalyticsEvents(input) {
|
|
|
3123
3600
|
await client.query("BEGIN");
|
|
3124
3601
|
for (const event of input.events) {
|
|
3125
3602
|
try {
|
|
3126
|
-
|
|
3127
|
-
const identifiedPersonId = event.identity && event.consent?.analytics === "granted" ? await linkAnalyticsIdentityInTransaction(client, {
|
|
3603
|
+
await projectAnalyticsPixelEventConsent(client, {
|
|
3128
3604
|
siteId: pixel.site_id,
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
clickIds: event.clickIds
|
|
3136
|
-
}) : null;
|
|
3137
|
-
const resolvedPerson = identifiedPersonId ? { rows: [{ person_id: identifiedPersonId }] } : event.visitorId || event.sessionId ? await client.query(
|
|
3605
|
+
pixelId: pixel.id,
|
|
3606
|
+
event
|
|
3607
|
+
});
|
|
3608
|
+
const attribution = resolveAnalyticsAttribution(event);
|
|
3609
|
+
const eventIdentity = event.identity;
|
|
3610
|
+
const resolvedPerson = analyticsIdentityResolutionAllowed(event) && (event.visitorId || event.sessionId) ? await client.query(
|
|
3138
3611
|
`SELECT e.person_id FROM analytics_identity_nodes n JOIN analytics_identity_edges e ON e.identity_node_id = n.id
|
|
3139
3612
|
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))
|
|
3140
3613
|
ORDER BY e.confidence DESC, e.last_seen_at DESC LIMIT 1`,
|
|
@@ -3156,7 +3629,7 @@ async function ingestAnalyticsEvents(input) {
|
|
|
3156
3629
|
$23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33
|
|
3157
3630
|
) ON CONFLICT(site_id, event_id) DO NOTHING`,
|
|
3158
3631
|
[
|
|
3159
|
-
|
|
3632
|
+
randomUUID2(),
|
|
3160
3633
|
event.eventId,
|
|
3161
3634
|
pixel.site_id,
|
|
3162
3635
|
pixel.id,
|
|
@@ -3193,13 +3666,34 @@ async function ingestAnalyticsEvents(input) {
|
|
|
3193
3666
|
);
|
|
3194
3667
|
if (inserted.rowCount) {
|
|
3195
3668
|
accepted += 1;
|
|
3669
|
+
const resolvedPersonId = resolvedPerson.rows[0]?.person_id;
|
|
3196
3670
|
await materializeAnalyticsSessionAndTouch({
|
|
3197
3671
|
client,
|
|
3198
3672
|
siteId: pixel.site_id,
|
|
3199
3673
|
pixelId: pixel.id,
|
|
3200
|
-
personId:
|
|
3674
|
+
personId: resolvedPersonId,
|
|
3201
3675
|
event
|
|
3202
3676
|
});
|
|
3677
|
+
if (resolvedPersonId && event.eventName === "identify") {
|
|
3678
|
+
await backfillAnalyticsConfirmedHistory(client, {
|
|
3679
|
+
siteId: pixel.site_id,
|
|
3680
|
+
personId: resolvedPersonId,
|
|
3681
|
+
visitorId: event.visitorId,
|
|
3682
|
+
sessionId: event.sessionId
|
|
3683
|
+
});
|
|
3684
|
+
}
|
|
3685
|
+
if (eventIdentity && analyticsIdentityPromotionAllowed(event)) {
|
|
3686
|
+
await linkAnalyticsIdentityInTransaction(client, {
|
|
3687
|
+
siteId: pixel.site_id,
|
|
3688
|
+
visitorId: event.visitorId,
|
|
3689
|
+
sessionId: event.sessionId,
|
|
3690
|
+
email: eventIdentity.email,
|
|
3691
|
+
phone: eventIdentity.phone,
|
|
3692
|
+
customerId: eventIdentity.customerId,
|
|
3693
|
+
orderId: eventIdentity.orderId,
|
|
3694
|
+
clickIds: event.clickIds
|
|
3695
|
+
});
|
|
3696
|
+
}
|
|
3203
3697
|
} else {
|
|
3204
3698
|
rejected += 1;
|
|
3205
3699
|
reasonCodes.add("duplicate_event");
|
|
@@ -3222,7 +3716,7 @@ async function ingestAnalyticsEvents(input) {
|
|
|
3222
3716
|
id, request_id, site_id, pixel_id, hostname, accepted_count, rejected_count, reason_codes
|
|
3223
3717
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)`,
|
|
3224
3718
|
[
|
|
3225
|
-
|
|
3719
|
+
randomUUID2(),
|
|
3226
3720
|
requestId,
|
|
3227
3721
|
pixel.site_id,
|
|
3228
3722
|
pixel.id,
|
|
@@ -3254,7 +3748,7 @@ function normalizeGeographyCode(value, max) {
|
|
|
3254
3748
|
return normalized && /^[A-Z0-9-]+$/.test(normalized) ? normalized.slice(0, max) : null;
|
|
3255
3749
|
}
|
|
3256
3750
|
function pageFingerprint(scope, siteId, filters) {
|
|
3257
|
-
return
|
|
3751
|
+
return createHash2("sha256").update(JSON.stringify({ scope, siteId, filters })).digest("base64url").slice(0, 18);
|
|
3258
3752
|
}
|
|
3259
3753
|
function decodePageOffset(cursor, fingerprint) {
|
|
3260
3754
|
if (!cursor) return 0;
|
|
@@ -3313,7 +3807,7 @@ async function createAnalyticsConversion(input) {
|
|
|
3313
3807
|
ORDER BY e.confidence DESC LIMIT 1`,
|
|
3314
3808
|
[input.siteId, identityHmac("session_id", input.sessionId)]
|
|
3315
3809
|
) : { rows: [] };
|
|
3316
|
-
const proposedId =
|
|
3810
|
+
const proposedId = randomUUID2();
|
|
3317
3811
|
const inserted = await client.query(
|
|
3318
3812
|
`INSERT INTO analytics_conversions(
|
|
3319
3813
|
id, source_event_id, site_id, pixel_id, session_id, conversion_kind,
|
|
@@ -3353,7 +3847,7 @@ async function createAnalyticsConversion(input) {
|
|
|
3353
3847
|
) VALUES ($1,$2,$3,$4,$5)
|
|
3354
3848
|
ON CONFLICT(destination_id, conversion_id) DO NOTHING`,
|
|
3355
3849
|
[
|
|
3356
|
-
|
|
3850
|
+
randomUUID2(),
|
|
3357
3851
|
destination.id,
|
|
3358
3852
|
conversionId,
|
|
3359
3853
|
resolvedPerson.rows[0]?.person_id ?? null,
|
|
@@ -4117,7 +4611,7 @@ async function createAnalyticsCampaignLink(input) {
|
|
|
4117
4611
|
404
|
|
4118
4612
|
);
|
|
4119
4613
|
}
|
|
4120
|
-
const shortCode = (input.shortCode?.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-") ||
|
|
4614
|
+
const shortCode = (input.shortCode?.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-") || randomBytes2(5).toString("base64url").toLowerCase()).slice(0, 48);
|
|
4121
4615
|
try {
|
|
4122
4616
|
const result = await db.query(
|
|
4123
4617
|
`INSERT INTO analytics_campaign_links(
|
|
@@ -4126,7 +4620,7 @@ async function createAnalyticsCampaignLink(input) {
|
|
|
4126
4620
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
|
|
4127
4621
|
RETURNING *, 0::int AS click_count`,
|
|
4128
4622
|
[
|
|
4129
|
-
|
|
4623
|
+
randomUUID2(),
|
|
4130
4624
|
input.siteId,
|
|
4131
4625
|
input.pixelId ?? null,
|
|
4132
4626
|
input.name.trim(),
|
|
@@ -4197,7 +4691,7 @@ async function resolveAnalyticsCampaignLink(shortCode, referrer) {
|
|
|
4197
4691
|
if (!row) return null;
|
|
4198
4692
|
await db.query(
|
|
4199
4693
|
`INSERT INTO analytics_campaign_clicks(id, link_id, referrer) VALUES ($1, $2, $3)`,
|
|
4200
|
-
[
|
|
4694
|
+
[randomUUID2(), row.id, normalizeAnalyticsUrl(referrer || void 0)]
|
|
4201
4695
|
);
|
|
4202
4696
|
return buildTaggedCampaignUrl(row);
|
|
4203
4697
|
}
|
|
@@ -4215,14 +4709,14 @@ async function createAnalyticsForm(input) {
|
|
|
4215
4709
|
404
|
|
4216
4710
|
);
|
|
4217
4711
|
const baseSlug = normalizeSlug(input.name);
|
|
4218
|
-
const slug = `${baseSlug}-${
|
|
4219
|
-
const publicId = `form_${
|
|
4712
|
+
const slug = `${baseSlug}-${randomBytes2(3).toString("hex")}`;
|
|
4713
|
+
const publicId = `form_${randomBytes2(18).toString("base64url")}`;
|
|
4220
4714
|
const result = await db.query(
|
|
4221
4715
|
`INSERT INTO analytics_forms(id, public_id, site_id, pixel_id, name, slug, fields, brand, submit_label, success_message, redirect_url, consent_text, status, created_by_user_id)
|
|
4222
4716
|
VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9,$10,$11,$12,$13,$14)
|
|
4223
4717
|
RETURNING *, 0::int AS submission_count`,
|
|
4224
4718
|
[
|
|
4225
|
-
|
|
4719
|
+
randomUUID2(),
|
|
4226
4720
|
publicId,
|
|
4227
4721
|
input.siteId,
|
|
4228
4722
|
input.pixelId,
|
|
@@ -4267,7 +4761,7 @@ async function getPublicAnalyticsForm(publicId) {
|
|
|
4267
4761
|
return result.rows[0] ?? null;
|
|
4268
4762
|
}
|
|
4269
4763
|
async function recordAnalyticsFormSubmission(input) {
|
|
4270
|
-
const id =
|
|
4764
|
+
const id = randomUUID2();
|
|
4271
4765
|
await getAnalyticsPool().query(
|
|
4272
4766
|
`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)
|
|
4273
4767
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb)`,
|
|
@@ -4311,18 +4805,262 @@ function sanitizeClickIds(value) {
|
|
|
4311
4805
|
return output;
|
|
4312
4806
|
}
|
|
4313
4807
|
function identityHmac(kind, value) {
|
|
4314
|
-
return
|
|
4808
|
+
return createHmac2("sha256", getSessionSecret()).update(`${kind}:${value.trim().toLowerCase()}`).digest("hex");
|
|
4809
|
+
}
|
|
4810
|
+
function newAnalyticsPersonPublicRef() {
|
|
4811
|
+
return `xrp_${randomBytes2(18).toString("base64url")}`;
|
|
4812
|
+
}
|
|
4813
|
+
async function ensurePrimaryAnalyticsIdentityNamespace(client, siteId, source) {
|
|
4814
|
+
let namespace = await client.query(
|
|
4815
|
+
"SELECT id FROM analytics_identity_namespaces WHERE site_id=$1 AND status='approved' ORDER BY version DESC LIMIT 1",
|
|
4816
|
+
[siteId]
|
|
4817
|
+
);
|
|
4818
|
+
if (!namespace.rows[0]) {
|
|
4819
|
+
await client.query(
|
|
4820
|
+
`INSERT INTO analytics_identity_namespaces(
|
|
4821
|
+
id,site_id,name,status,approved_hosts,approved_sources,version,approved_by_user_id,approved_at
|
|
4822
|
+
)
|
|
4823
|
+
SELECT $1,s.id,'X-Ray primary','approved',
|
|
4824
|
+
COALESCE((SELECT jsonb_agg(d.hostname ORDER BY d.hostname)
|
|
4825
|
+
FROM analytics_pixels p
|
|
4826
|
+
JOIN analytics_pixel_domains d ON d.pixel_id=p.id AND d.state='approved'
|
|
4827
|
+
WHERE p.site_id=s.id),'[]'::jsonb),
|
|
4828
|
+
$3::jsonb,1,s.owner_user_id,now()
|
|
4829
|
+
FROM analytics_sites s WHERE s.id=$2
|
|
4830
|
+
ON CONFLICT(site_id,name,version) DO NOTHING`,
|
|
4831
|
+
[randomUUID2(), siteId, JSON.stringify([source])]
|
|
4832
|
+
);
|
|
4833
|
+
namespace = await client.query(
|
|
4834
|
+
"SELECT id FROM analytics_identity_namespaces WHERE site_id=$1 AND status='approved' ORDER BY version DESC LIMIT 1",
|
|
4835
|
+
[siteId]
|
|
4836
|
+
);
|
|
4837
|
+
}
|
|
4838
|
+
const namespaceId = namespace.rows[0]?.id;
|
|
4839
|
+
if (!namespaceId)
|
|
4840
|
+
throw new AnalyticsRepositoryError(
|
|
4841
|
+
"identity_namespace_unavailable",
|
|
4842
|
+
"The Site identity graph could not be initialized.",
|
|
4843
|
+
409
|
|
4844
|
+
);
|
|
4845
|
+
await client.query(
|
|
4846
|
+
`UPDATE analytics_identity_namespaces
|
|
4847
|
+
SET approved_sources=(SELECT jsonb_agg(value ORDER BY value)
|
|
4848
|
+
FROM (SELECT DISTINCT value
|
|
4849
|
+
FROM jsonb_array_elements_text(approved_sources || $3::jsonb)) merged_sources),
|
|
4850
|
+
approved_hosts=(SELECT COALESCE(jsonb_agg(hostname ORDER BY hostname),'[]'::jsonb)
|
|
4851
|
+
FROM (SELECT DISTINCT hostname
|
|
4852
|
+
FROM (SELECT jsonb_array_elements_text(approved_hosts) AS hostname
|
|
4853
|
+
UNION ALL
|
|
4854
|
+
SELECT d.hostname
|
|
4855
|
+
FROM analytics_pixels p
|
|
4856
|
+
JOIN analytics_pixel_domains d ON d.pixel_id=p.id AND d.state='approved'
|
|
4857
|
+
WHERE p.site_id=$2) hosts) distinct_hosts),
|
|
4858
|
+
updated_at=now()
|
|
4859
|
+
WHERE id=$1 AND site_id=$2`,
|
|
4860
|
+
[namespaceId, siteId, JSON.stringify([source])]
|
|
4861
|
+
);
|
|
4862
|
+
return namespaceId;
|
|
4863
|
+
}
|
|
4864
|
+
async function backfillAnalyticsConfirmedHistory(client, input) {
|
|
4865
|
+
if (!input.visitorId && !input.sessionId)
|
|
4866
|
+
return { events: 0, sessions: 0, touches: 0, conversions: 0 };
|
|
4867
|
+
const visitorHmac = input.visitorId ? identityHmac("visitor_id", input.visitorId) : null;
|
|
4868
|
+
const sessionHmac = input.sessionId ? identityHmac("session_id", input.sessionId) : null;
|
|
4869
|
+
const events = await client.query(
|
|
4870
|
+
`UPDATE analytics_events SET person_id = $1 WHERE site_id = $2 AND person_id IS NULL
|
|
4871
|
+
AND (($3::text IS NOT NULL AND visitor_id = $3) OR ($4::text IS NOT NULL AND session_id = $4))`,
|
|
4872
|
+
[input.personId, input.siteId, input.visitorId ?? null, input.sessionId ?? null]
|
|
4873
|
+
);
|
|
4874
|
+
const conversions = await client.query(
|
|
4875
|
+
`UPDATE analytics_conversions SET person_id = $1 WHERE site_id = $2 AND person_id IS NULL
|
|
4876
|
+
AND $3::text IS NOT NULL AND session_id = $3`,
|
|
4877
|
+
[input.personId, input.siteId, input.sessionId ?? null]
|
|
4878
|
+
);
|
|
4879
|
+
const sessions = await client.query(
|
|
4880
|
+
`UPDATE analytics_sessions SET person_id = $1
|
|
4881
|
+
WHERE site_id = $2 AND person_id IS NULL
|
|
4882
|
+
AND (($3::text IS NOT NULL AND visitor_hmac = $3)
|
|
4883
|
+
OR ($4::text IS NOT NULL AND session_hmac = $4))`,
|
|
4884
|
+
[input.personId, input.siteId, visitorHmac, sessionHmac]
|
|
4885
|
+
);
|
|
4886
|
+
const touches = await client.query(
|
|
4887
|
+
`UPDATE analytics_attribution_touches SET person_id = $1
|
|
4888
|
+
WHERE site_id = $2 AND person_id IS NULL
|
|
4889
|
+
AND (($3::text IS NOT NULL AND visitor_hmac = $3)
|
|
4890
|
+
OR ($4::text IS NOT NULL AND session_id IN (
|
|
4891
|
+
SELECT id FROM analytics_sessions
|
|
4892
|
+
WHERE site_id = $2 AND session_hmac = $4
|
|
4893
|
+
)))`,
|
|
4894
|
+
[input.personId, input.siteId, visitorHmac, sessionHmac]
|
|
4895
|
+
);
|
|
4896
|
+
return {
|
|
4897
|
+
events: events.rowCount ?? 0,
|
|
4898
|
+
sessions: sessions.rowCount ?? 0,
|
|
4899
|
+
touches: touches.rowCount ?? 0,
|
|
4900
|
+
conversions: conversions.rowCount ?? 0
|
|
4901
|
+
};
|
|
4902
|
+
}
|
|
4903
|
+
async function resolveAndMergeConfirmedAnalyticsPerson(client, input) {
|
|
4904
|
+
const matches = await client.query(
|
|
4905
|
+
`SELECT p.id
|
|
4906
|
+
FROM analytics_people p
|
|
4907
|
+
JOIN (
|
|
4908
|
+
SELECT DISTINCT e.person_id
|
|
4909
|
+
FROM analytics_identity_nodes n
|
|
4910
|
+
JOIN analytics_identity_edges e ON e.identity_node_id=n.id AND e.site_id=n.site_id
|
|
4911
|
+
WHERE n.site_id=$1 AND EXISTS (
|
|
4912
|
+
SELECT 1 FROM jsonb_to_recordset($2::jsonb) signal(kind text,value_hmac text)
|
|
4913
|
+
WHERE signal.kind=n.kind AND signal.value_hmac=n.value_hmac
|
|
4914
|
+
)
|
|
4915
|
+
) matched ON matched.person_id=p.id
|
|
4916
|
+
WHERE p.site_id=$1 AND (p.namespace_id=$3 OR p.namespace_id IS NULL)
|
|
4917
|
+
ORDER BY p.first_seen_at ASC,p.id ASC
|
|
4918
|
+
FOR UPDATE OF p`,
|
|
4919
|
+
[
|
|
4920
|
+
input.siteId,
|
|
4921
|
+
JSON.stringify(input.signals.map((signal) => ({ kind: signal.kind, value_hmac: signal.valueHmac }))),
|
|
4922
|
+
input.namespaceId
|
|
4923
|
+
]
|
|
4924
|
+
);
|
|
4925
|
+
const canonicalId = matches.rows[0]?.id;
|
|
4926
|
+
if (!canonicalId) {
|
|
4927
|
+
const inserted = await client.query(
|
|
4928
|
+
`INSERT INTO analytics_people(id, site_id, namespace_id, crm_person_ref, public_ref) VALUES ($1,$2,$3,$4,$5)
|
|
4929
|
+
ON CONFLICT(site_id, crm_person_ref) DO UPDATE SET last_seen_at = now(),
|
|
4930
|
+
namespace_id=COALESCE(analytics_people.namespace_id,EXCLUDED.namespace_id),
|
|
4931
|
+
public_ref=COALESCE(analytics_people.public_ref,EXCLUDED.public_ref) RETURNING id`,
|
|
4932
|
+
[randomUUID2(), input.siteId, input.namespaceId, input.crmPersonRef, newAnalyticsPersonPublicRef()]
|
|
4933
|
+
);
|
|
4934
|
+
return inserted.rows[0].id;
|
|
4935
|
+
}
|
|
4936
|
+
await client.query(
|
|
4937
|
+
`UPDATE analytics_people SET last_seen_at=now(),
|
|
4938
|
+
namespace_id=COALESCE(namespace_id,$2),public_ref=COALESCE(public_ref,$3)
|
|
4939
|
+
WHERE id=$1 AND site_id=$4`,
|
|
4940
|
+
[canonicalId, input.namespaceId, newAnalyticsPersonPublicRef(), input.siteId]
|
|
4941
|
+
);
|
|
4942
|
+
const duplicateIds = matches.rows.slice(1).map((row) => row.id);
|
|
4943
|
+
if (duplicateIds.length === 0) return canonicalId;
|
|
4944
|
+
await client.query(
|
|
4945
|
+
`DELETE FROM analytics_identity_edges duplicate
|
|
4946
|
+
USING analytics_identity_edges canonical
|
|
4947
|
+
WHERE duplicate.site_id=$1 AND duplicate.person_id=ANY($2::uuid[])
|
|
4948
|
+
AND canonical.site_id=duplicate.site_id AND canonical.person_id=$3
|
|
4949
|
+
AND canonical.identity_node_id=duplicate.identity_node_id
|
|
4950
|
+
AND canonical.evidence_kind=duplicate.evidence_kind`,
|
|
4951
|
+
[input.siteId, duplicateIds, canonicalId]
|
|
4952
|
+
);
|
|
4953
|
+
await client.query(
|
|
4954
|
+
`UPDATE analytics_identity_edges SET person_id=$3,namespace_id=$4
|
|
4955
|
+
WHERE site_id=$1 AND person_id=ANY($2::uuid[])`,
|
|
4956
|
+
[input.siteId, duplicateIds, canonicalId, input.namespaceId]
|
|
4957
|
+
);
|
|
4958
|
+
for (const table of [
|
|
4959
|
+
"analytics_form_submissions",
|
|
4960
|
+
"analytics_events",
|
|
4961
|
+
"analytics_conversions",
|
|
4962
|
+
"analytics_sessions",
|
|
4963
|
+
"analytics_attribution_touches",
|
|
4964
|
+
"analytics_external_events",
|
|
4965
|
+
"analytics_restricted_data",
|
|
4966
|
+
"analytics_crm_outbound_jobs"
|
|
4967
|
+
]) {
|
|
4968
|
+
await client.query(
|
|
4969
|
+
`UPDATE ${table} SET person_id=$3 WHERE site_id=$1 AND person_id=ANY($2::uuid[])`,
|
|
4970
|
+
[input.siteId, duplicateIds, canonicalId]
|
|
4971
|
+
);
|
|
4972
|
+
}
|
|
4973
|
+
await client.query(
|
|
4974
|
+
"UPDATE analytics_activation_jobs SET person_id=$2 WHERE person_id=ANY($1::uuid[])",
|
|
4975
|
+
[duplicateIds, canonicalId]
|
|
4976
|
+
);
|
|
4977
|
+
await client.query(
|
|
4978
|
+
`UPDATE analytics_candidate_associations SET promoted_person_id=$3,namespace_id=$4
|
|
4979
|
+
WHERE site_id=$1 AND promoted_person_id=ANY($2::uuid[])`,
|
|
4980
|
+
[input.siteId, duplicateIds, canonicalId, input.namespaceId]
|
|
4981
|
+
);
|
|
4982
|
+
await client.query(
|
|
4983
|
+
`DELETE FROM analytics_candidate_promotions duplicate
|
|
4984
|
+
USING analytics_candidate_promotions canonical
|
|
4985
|
+
WHERE duplicate.site_id=$1 AND duplicate.person_id=ANY($2::uuid[])
|
|
4986
|
+
AND canonical.site_id=duplicate.site_id AND canonical.person_id=$3
|
|
4987
|
+
AND canonical.association_id=duplicate.association_id`,
|
|
4988
|
+
[input.siteId, duplicateIds, canonicalId]
|
|
4989
|
+
);
|
|
4990
|
+
await client.query(
|
|
4991
|
+
`UPDATE analytics_candidate_promotions SET person_id=$3,namespace_id=$4
|
|
4992
|
+
WHERE site_id=$1 AND person_id=ANY($2::uuid[])`,
|
|
4993
|
+
[input.siteId, duplicateIds, canonicalId, input.namespaceId]
|
|
4994
|
+
);
|
|
4995
|
+
await client.query(
|
|
4996
|
+
`DELETE FROM analytics_crm_person_mappings duplicate
|
|
4997
|
+
USING analytics_crm_person_mappings canonical
|
|
4998
|
+
WHERE duplicate.site_id=$1 AND duplicate.person_id=ANY($2::uuid[])
|
|
4999
|
+
AND canonical.site_id=duplicate.site_id AND canonical.person_id=$3
|
|
5000
|
+
AND canonical.connection_id=duplicate.connection_id`,
|
|
5001
|
+
[input.siteId, duplicateIds, canonicalId]
|
|
5002
|
+
);
|
|
5003
|
+
await client.query(
|
|
5004
|
+
`UPDATE analytics_crm_person_mappings SET person_id=$3,namespace_id=$4
|
|
5005
|
+
WHERE site_id=$1 AND person_id=ANY($2::uuid[])`,
|
|
5006
|
+
[input.siteId, duplicateIds, canonicalId, input.namespaceId]
|
|
5007
|
+
);
|
|
5008
|
+
await client.query(
|
|
5009
|
+
`DELETE FROM analytics_prediction_results duplicate
|
|
5010
|
+
USING analytics_prediction_results canonical
|
|
5011
|
+
WHERE duplicate.site_id=$1 AND duplicate.person_id=ANY($2::uuid[])
|
|
5012
|
+
AND canonical.site_id=duplicate.site_id AND canonical.person_id=$3
|
|
5013
|
+
AND canonical.manifest_id=duplicate.manifest_id`,
|
|
5014
|
+
[input.siteId, duplicateIds, canonicalId]
|
|
5015
|
+
);
|
|
5016
|
+
for (const table of ["analytics_score_evaluations", "analytics_prediction_results"]) {
|
|
5017
|
+
await client.query(
|
|
5018
|
+
`UPDATE ${table} SET person_id=$3,namespace_id=$4
|
|
5019
|
+
WHERE site_id=$1 AND person_id=ANY($2::uuid[])`,
|
|
5020
|
+
[input.siteId, duplicateIds, canonicalId, input.namespaceId]
|
|
5021
|
+
);
|
|
5022
|
+
}
|
|
5023
|
+
await client.query(
|
|
5024
|
+
"UPDATE analytics_identity_profile_access_audits SET person_id=$3 WHERE site_id=$1 AND person_id=ANY($2::uuid[])",
|
|
5025
|
+
[input.siteId, duplicateIds, canonicalId]
|
|
5026
|
+
);
|
|
5027
|
+
await client.query(
|
|
5028
|
+
"UPDATE analytics_identity_tombstones SET person_id=$3 WHERE site_id=$1 AND person_id=ANY($2::uuid[])",
|
|
5029
|
+
[input.siteId, duplicateIds, canonicalId]
|
|
5030
|
+
);
|
|
5031
|
+
await client.query(
|
|
5032
|
+
"DELETE FROM analytics_people WHERE site_id=$1 AND id=ANY($2::uuid[])",
|
|
5033
|
+
[input.siteId, duplicateIds]
|
|
5034
|
+
);
|
|
5035
|
+
return canonicalId;
|
|
4315
5036
|
}
|
|
4316
5037
|
async function linkAnalyticsIdentityInTransaction(client, input) {
|
|
4317
5038
|
const stable = input.customerId || input.orderId || input.email || input.phone;
|
|
4318
5039
|
if (!stable) return null;
|
|
5040
|
+
if (!await hasCurrentAnalyticsIdentityConsent(
|
|
5041
|
+
client,
|
|
5042
|
+
input.siteId,
|
|
5043
|
+
input.visitorId,
|
|
5044
|
+
input.sessionId
|
|
5045
|
+
)) return null;
|
|
5046
|
+
const namespaceId = await ensurePrimaryAnalyticsIdentityNamespace(client, input.siteId, "pixel");
|
|
5047
|
+
const tombstoneSignals = [
|
|
5048
|
+
...input.email ? [{ kind: "email", valueHmac: identityHmac("email", input.email) }] : [],
|
|
5049
|
+
...input.phone ? [{ kind: "phone", valueHmac: identityHmac("phone", input.phone) }] : [],
|
|
5050
|
+
...input.customerId ? [{ kind: "customer_id", valueHmac: identityHmac("customer_id", input.customerId) }] : [],
|
|
5051
|
+
...input.orderId ? [{ kind: "customer_id", valueHmac: identityHmac("customer_id", `order:${input.orderId}`) }] : []
|
|
5052
|
+
];
|
|
5053
|
+
await assertAnalyticsIdentitySignalsNotTombstoned(client, {
|
|
5054
|
+
siteId: input.siteId,
|
|
5055
|
+
signals: tombstoneSignals
|
|
5056
|
+
});
|
|
4319
5057
|
const crmPersonRef = `XRay::${identityHmac("person_ref", stable).slice(0, 24)}`;
|
|
4320
|
-
const
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
5058
|
+
const personId = await resolveAndMergeConfirmedAnalyticsPerson(client, {
|
|
5059
|
+
siteId: input.siteId,
|
|
5060
|
+
namespaceId,
|
|
5061
|
+
crmPersonRef,
|
|
5062
|
+
signals: tombstoneSignals
|
|
5063
|
+
});
|
|
4326
5064
|
const signals = [];
|
|
4327
5065
|
if (input.visitorId)
|
|
4328
5066
|
signals.push({ kind: "visitor_id", value: input.visitorId, evidence: "pixel_identify", confidence: 0.98 });
|
|
@@ -4340,30 +5078,66 @@ async function linkAnalyticsIdentityInTransaction(client, input) {
|
|
|
4340
5078
|
signals.push({ kind: "click_id", value: `${platform}:${clickId}`, evidence: platform, confidence: 0.9 });
|
|
4341
5079
|
for (const signal of signals) {
|
|
4342
5080
|
const node = await client.query(
|
|
4343
|
-
`INSERT INTO analytics_identity_nodes(id, site_id, kind, value_hmac) VALUES ($1,$2,$3,$4)
|
|
4344
|
-
ON CONFLICT(site_id, kind, value_hmac) DO UPDATE SET last_seen_at = now()
|
|
4345
|
-
|
|
5081
|
+
`INSERT INTO analytics_identity_nodes(id, site_id, namespace_id, kind, value_hmac) VALUES ($1,$2,$3,$4,$5)
|
|
5082
|
+
ON CONFLICT(site_id, kind, value_hmac) DO UPDATE SET last_seen_at = now(),
|
|
5083
|
+
namespace_id=COALESCE(analytics_identity_nodes.namespace_id,EXCLUDED.namespace_id) RETURNING id`,
|
|
5084
|
+
[randomUUID2(), input.siteId, namespaceId, signal.kind, identityHmac(signal.kind, signal.value)]
|
|
4346
5085
|
);
|
|
4347
5086
|
await client.query(
|
|
4348
|
-
`INSERT INTO analytics_identity_edges(id, site_id, person_id, identity_node_id, evidence_kind, confidence)
|
|
4349
|
-
VALUES ($1,$2,$3,$4,$5,$6)
|
|
5087
|
+
`INSERT INTO analytics_identity_edges(id, site_id, namespace_id, person_id, identity_node_id, evidence_kind, confidence)
|
|
5088
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
|
4350
5089
|
ON CONFLICT(person_id, identity_node_id, evidence_kind) DO UPDATE
|
|
4351
|
-
SET last_seen_at = now(), confidence = greatest(analytics_identity_edges.confidence, EXCLUDED.confidence)
|
|
4352
|
-
|
|
5090
|
+
SET last_seen_at = now(), confidence = greatest(analytics_identity_edges.confidence, EXCLUDED.confidence),
|
|
5091
|
+
namespace_id=COALESCE(analytics_identity_edges.namespace_id,EXCLUDED.namespace_id)`,
|
|
5092
|
+
[randomUUID2(), input.siteId, namespaceId, personId, node.rows[0].id, signal.evidence, signal.confidence]
|
|
4353
5093
|
);
|
|
4354
5094
|
}
|
|
5095
|
+
await backfillAnalyticsConfirmedHistory(client, {
|
|
5096
|
+
siteId: input.siteId,
|
|
5097
|
+
personId,
|
|
5098
|
+
visitorId: input.visitorId,
|
|
5099
|
+
sessionId: input.sessionId
|
|
5100
|
+
});
|
|
5101
|
+
if (input.customerId) {
|
|
5102
|
+
await writeAnalyticsConfirmedIdentityProfile(client, {
|
|
5103
|
+
siteId: input.siteId,
|
|
5104
|
+
personId,
|
|
5105
|
+
consentSubjectRefs: [input.visitorId ?? "", input.sessionId ?? ""],
|
|
5106
|
+
evidenceType: "authenticated_user",
|
|
5107
|
+
email: input.email,
|
|
5108
|
+
phone: input.phone
|
|
5109
|
+
});
|
|
5110
|
+
}
|
|
4355
5111
|
return personId;
|
|
4356
5112
|
}
|
|
4357
5113
|
async function linkAnalyticsFormIdentity(input) {
|
|
4358
5114
|
const client = await getAnalyticsPool().connect();
|
|
4359
5115
|
try {
|
|
4360
5116
|
await client.query("BEGIN");
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
|
|
4364
|
-
|
|
4365
|
-
|
|
4366
|
-
|
|
5117
|
+
if (!await hasCurrentAnalyticsIdentityConsent(
|
|
5118
|
+
client,
|
|
5119
|
+
input.siteId,
|
|
5120
|
+
input.visitorId,
|
|
5121
|
+
input.sessionId
|
|
5122
|
+
)) {
|
|
5123
|
+
await client.query("COMMIT");
|
|
5124
|
+
return { personId: null, linkedSignals: 0 };
|
|
5125
|
+
}
|
|
5126
|
+
const namespaceId = await ensurePrimaryAnalyticsIdentityNamespace(client, input.siteId, "form");
|
|
5127
|
+
const deterministicSignals = [
|
|
5128
|
+
...input.email ? [{ kind: "email", valueHmac: identityHmac("email", input.email) }] : [],
|
|
5129
|
+
...input.phone ? [{ kind: "phone", valueHmac: identityHmac("phone", input.phone) }] : []
|
|
5130
|
+
];
|
|
5131
|
+
await assertAnalyticsIdentitySignalsNotTombstoned(client, {
|
|
5132
|
+
siteId: input.siteId,
|
|
5133
|
+
signals: deterministicSignals
|
|
5134
|
+
});
|
|
5135
|
+
const personId = await resolveAndMergeConfirmedAnalyticsPerson(client, {
|
|
5136
|
+
siteId: input.siteId,
|
|
5137
|
+
namespaceId,
|
|
5138
|
+
crmPersonRef: input.crmPersonRef,
|
|
5139
|
+
signals: deterministicSignals
|
|
5140
|
+
});
|
|
4367
5141
|
const signals = [];
|
|
4368
5142
|
if (input.visitorId)
|
|
4369
5143
|
signals.push({
|
|
@@ -4405,22 +5179,27 @@ async function linkAnalyticsFormIdentity(input) {
|
|
|
4405
5179
|
}
|
|
4406
5180
|
for (const signal of signals) {
|
|
4407
5181
|
const node = await client.query(
|
|
4408
|
-
`INSERT INTO analytics_identity_nodes(id, site_id, kind, value_hmac) VALUES ($1,$2,$3,$4)
|
|
4409
|
-
ON CONFLICT(site_id, kind, value_hmac) DO UPDATE SET last_seen_at = now()
|
|
5182
|
+
`INSERT INTO analytics_identity_nodes(id, site_id, namespace_id, kind, value_hmac) VALUES ($1,$2,$3,$4,$5)
|
|
5183
|
+
ON CONFLICT(site_id, kind, value_hmac) DO UPDATE SET last_seen_at = now(),
|
|
5184
|
+
namespace_id=COALESCE(analytics_identity_nodes.namespace_id,EXCLUDED.namespace_id) RETURNING id`,
|
|
4410
5185
|
[
|
|
4411
|
-
|
|
5186
|
+
randomUUID2(),
|
|
4412
5187
|
input.siteId,
|
|
5188
|
+
namespaceId,
|
|
4413
5189
|
signal.kind,
|
|
4414
5190
|
identityHmac(signal.kind, signal.value)
|
|
4415
5191
|
]
|
|
4416
5192
|
);
|
|
4417
5193
|
await client.query(
|
|
4418
|
-
`INSERT INTO analytics_identity_edges(id, site_id, person_id, identity_node_id, evidence_kind, confidence)
|
|
4419
|
-
VALUES ($1,$2,$3,$4,$5,$6)
|
|
4420
|
-
ON CONFLICT(person_id, identity_node_id, evidence_kind) DO UPDATE SET last_seen_at = now(),
|
|
5194
|
+
`INSERT INTO analytics_identity_edges(id, site_id, namespace_id, person_id, identity_node_id, evidence_kind, confidence)
|
|
5195
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
|
5196
|
+
ON CONFLICT(person_id, identity_node_id, evidence_kind) DO UPDATE SET last_seen_at = now(),
|
|
5197
|
+
confidence = greatest(analytics_identity_edges.confidence, EXCLUDED.confidence),
|
|
5198
|
+
namespace_id=COALESCE(analytics_identity_edges.namespace_id,EXCLUDED.namespace_id)`,
|
|
4421
5199
|
[
|
|
4422
|
-
|
|
5200
|
+
randomUUID2(),
|
|
4423
5201
|
input.siteId,
|
|
5202
|
+
namespaceId,
|
|
4424
5203
|
personId,
|
|
4425
5204
|
node.rows[0].id,
|
|
4426
5205
|
signal.evidence,
|
|
@@ -4434,42 +5213,80 @@ async function linkAnalyticsFormIdentity(input) {
|
|
|
4434
5213
|
[personId, input.submissionId, input.siteId]
|
|
4435
5214
|
);
|
|
4436
5215
|
if (input.visitorId || input.sessionId) {
|
|
4437
|
-
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
|
|
4456
|
-
|
|
4457
|
-
|
|
4458
|
-
|
|
5216
|
+
await backfillAnalyticsConfirmedHistory(client, {
|
|
5217
|
+
siteId: input.siteId,
|
|
5218
|
+
personId,
|
|
5219
|
+
visitorId: input.visitorId,
|
|
5220
|
+
sessionId: input.sessionId
|
|
5221
|
+
});
|
|
5222
|
+
}
|
|
5223
|
+
await writeAnalyticsConfirmedIdentityProfile(client, {
|
|
5224
|
+
siteId: input.siteId,
|
|
5225
|
+
personId,
|
|
5226
|
+
consentSubjectRefs: [input.visitorId ?? ""],
|
|
5227
|
+
evidenceType: input.evidenceType ?? "first_party_form",
|
|
5228
|
+
displayName: input.displayName,
|
|
5229
|
+
email: input.email,
|
|
5230
|
+
phone: input.phone
|
|
5231
|
+
});
|
|
5232
|
+
await client.query("COMMIT");
|
|
5233
|
+
return { personId, linkedSignals: signals.length };
|
|
5234
|
+
} catch (error) {
|
|
5235
|
+
await client.query("ROLLBACK");
|
|
5236
|
+
throw error;
|
|
5237
|
+
} finally {
|
|
5238
|
+
client.release();
|
|
5239
|
+
}
|
|
5240
|
+
}
|
|
5241
|
+
async function enrichAnalyticsExistingCrmIdentity(input) {
|
|
5242
|
+
const client = await getAnalyticsPool().connect();
|
|
5243
|
+
try {
|
|
5244
|
+
await client.query("BEGIN");
|
|
5245
|
+
const existing = await client.query(
|
|
5246
|
+
`SELECT p.id,p.public_ref,p.namespace_id FROM analytics_people p
|
|
5247
|
+
JOIN analytics_identity_namespaces n ON n.id=p.namespace_id AND n.site_id=p.site_id AND n.status='approved'
|
|
5248
|
+
WHERE p.site_id=$1 AND p.crm_person_ref=$2 LIMIT 1 FOR UPDATE OF p`,
|
|
5249
|
+
[input.siteId, input.crmPersonRef]
|
|
5250
|
+
);
|
|
5251
|
+
const person = existing.rows[0];
|
|
5252
|
+
if (!person || !await hasCurrentPersonIdentityConsent(client, input.siteId, person.public_ref)) {
|
|
5253
|
+
await client.query("COMMIT");
|
|
5254
|
+
return { personId: null, linkedSignals: 0 };
|
|
5255
|
+
}
|
|
5256
|
+
const signals = [
|
|
5257
|
+
...input.email ? [{ kind: "email", value: input.email, confidence: 1 }] : [],
|
|
5258
|
+
...input.phone ? [{ kind: "phone", value: input.phone, confidence: 0.99 }] : []
|
|
5259
|
+
];
|
|
5260
|
+
await assertAnalyticsIdentitySignalsNotTombstoned(client, {
|
|
5261
|
+
siteId: input.siteId,
|
|
5262
|
+
signals: signals.map((signal) => ({ kind: signal.kind, valueHmac: identityHmac(signal.kind, signal.value) }))
|
|
5263
|
+
});
|
|
5264
|
+
for (const signal of signals) {
|
|
5265
|
+
const node = await client.query(
|
|
5266
|
+
`INSERT INTO analytics_identity_nodes(id,site_id,namespace_id,kind,value_hmac) VALUES($1,$2,$3,$4,$5)
|
|
5267
|
+
ON CONFLICT(site_id,kind,value_hmac) DO UPDATE SET last_seen_at=now(),
|
|
5268
|
+
namespace_id=COALESCE(analytics_identity_nodes.namespace_id,EXCLUDED.namespace_id) RETURNING id`,
|
|
5269
|
+
[randomUUID2(), input.siteId, person.namespace_id, signal.kind, identityHmac(signal.kind, signal.value)]
|
|
4459
5270
|
);
|
|
4460
5271
|
await client.query(
|
|
4461
|
-
`
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
4465
|
-
|
|
4466
|
-
WHERE site_id = $2 AND session_hmac = $4
|
|
4467
|
-
)))`,
|
|
4468
|
-
[personId, input.siteId, visitorHmac, sessionHmac]
|
|
5272
|
+
`INSERT INTO analytics_identity_edges(id,site_id,namespace_id,person_id,identity_node_id,evidence_kind,confidence)
|
|
5273
|
+
VALUES($1,$2,$3,$4,$5,'crm_contact',$6)
|
|
5274
|
+
ON CONFLICT(person_id,identity_node_id,evidence_kind) DO UPDATE SET last_seen_at=now(),
|
|
5275
|
+
confidence=greatest(analytics_identity_edges.confidence,EXCLUDED.confidence)`,
|
|
5276
|
+
[randomUUID2(), input.siteId, person.namespace_id, person.id, node.rows[0].id, signal.confidence]
|
|
4469
5277
|
);
|
|
4470
5278
|
}
|
|
5279
|
+
await writeAnalyticsConfirmedIdentityProfile(client, {
|
|
5280
|
+
siteId: input.siteId,
|
|
5281
|
+
personId: person.id,
|
|
5282
|
+
consentSubjectRefs: [person.public_ref],
|
|
5283
|
+
evidenceType: "crm_contact",
|
|
5284
|
+
displayName: input.displayName,
|
|
5285
|
+
email: input.email,
|
|
5286
|
+
phone: input.phone
|
|
5287
|
+
});
|
|
4471
5288
|
await client.query("COMMIT");
|
|
4472
|
-
return { personId, linkedSignals: signals.length };
|
|
5289
|
+
return { personId: person.id, linkedSignals: signals.length };
|
|
4473
5290
|
} catch (error) {
|
|
4474
5291
|
await client.query("ROLLBACK");
|
|
4475
5292
|
throw error;
|
|
@@ -4536,7 +5353,7 @@ async function createAnalyticsCrmImport(input) {
|
|
|
4536
5353
|
const db = getAnalyticsPool();
|
|
4537
5354
|
await requireEditor(db, input.siteId, input.userId);
|
|
4538
5355
|
const client = await db.connect();
|
|
4539
|
-
const id =
|
|
5356
|
+
const id = randomUUID2();
|
|
4540
5357
|
try {
|
|
4541
5358
|
await client.query("BEGIN");
|
|
4542
5359
|
await client.query(
|
|
@@ -4556,7 +5373,7 @@ async function createAnalyticsCrmImport(input) {
|
|
|
4556
5373
|
await client.query(
|
|
4557
5374
|
`INSERT INTO analytics_crm_import_rows(id, import_id, crm_person_ref, payload_ciphertext)
|
|
4558
5375
|
VALUES ($1,$2,$3,$4) ON CONFLICT(import_id, crm_person_ref) DO NOTHING`,
|
|
4559
|
-
[
|
|
5376
|
+
[randomUUID2(), id, row.crmPersonRef, row.payloadCiphertext]
|
|
4560
5377
|
);
|
|
4561
5378
|
}
|
|
4562
5379
|
await client.query("COMMIT");
|
|
@@ -4679,7 +5496,7 @@ async function createAnalyticsActivationDestination(input) {
|
|
|
4679
5496
|
`INSERT INTO analytics_activation_destinations(id, site_id, platform, name, connection_ref, external_dataset_id, event_mapping, provider_config, created_by_user_id)
|
|
4680
5497
|
VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9) RETURNING *`,
|
|
4681
5498
|
[
|
|
4682
|
-
|
|
5499
|
+
randomUUID2(),
|
|
4683
5500
|
input.siteId,
|
|
4684
5501
|
input.platform,
|
|
4685
5502
|
input.name.trim(),
|
|
@@ -4826,8 +5643,8 @@ async function testAnalyticsActivationDestination(input) {
|
|
|
4826
5643
|
400
|
|
4827
5644
|
);
|
|
4828
5645
|
}
|
|
4829
|
-
const testId =
|
|
4830
|
-
const syntheticHash =
|
|
5646
|
+
const testId = randomUUID2();
|
|
5647
|
+
const syntheticHash = createHash2("sha256").update(`xray-provider-test:${testId}`).digest("hex");
|
|
4831
5648
|
const providerConfig = destination.provider_config ?? {};
|
|
4832
5649
|
const payload = {
|
|
4833
5650
|
eventId: `xray-test:${testId}`,
|
|
@@ -5064,7 +5881,7 @@ async function queueAnalyticsActivation(input) {
|
|
|
5064
5881
|
`INSERT INTO analytics_activation_jobs(id, destination_id, conversion_id, person_id, payload_ciphertext)
|
|
5065
5882
|
VALUES ($1,$2,$3,$4,$5) ON CONFLICT(destination_id, conversion_id) DO NOTHING`,
|
|
5066
5883
|
[
|
|
5067
|
-
|
|
5884
|
+
randomUUID2(),
|
|
5068
5885
|
destination.id,
|
|
5069
5886
|
input.conversionId,
|
|
5070
5887
|
input.personId ?? null,
|
|
@@ -5076,7 +5893,7 @@ async function queueAnalyticsActivation(input) {
|
|
|
5076
5893
|
return queued;
|
|
5077
5894
|
}
|
|
5078
5895
|
async function queueAnalyticsFormDelivery(input) {
|
|
5079
|
-
const id =
|
|
5896
|
+
const id = randomUUID2();
|
|
5080
5897
|
await getAnalyticsPool().query(
|
|
5081
5898
|
`INSERT INTO analytics_form_delivery_jobs(id, submission_id, owner_user_id, payload_ciphertext, last_error_code)
|
|
5082
5899
|
VALUES ($1,$2,$3,$4,$5)`,
|
|
@@ -5233,7 +6050,7 @@ async function analyticsHealth(siteId, userId) {
|
|
|
5233
6050
|
}
|
|
5234
6051
|
async function refreshAnalyticsDailyRollups(input) {
|
|
5235
6052
|
const db = getAnalyticsPool();
|
|
5236
|
-
const runId =
|
|
6053
|
+
const runId = randomUUID2();
|
|
5237
6054
|
await db.query(
|
|
5238
6055
|
`INSERT INTO analytics_rollup_runs(id, window_start, window_end, status) VALUES ($1, $2, $3, 'running')`,
|
|
5239
6056
|
[runId, input.start, input.end]
|
|
@@ -5430,7 +6247,7 @@ async function createAnalyticsExport(input) {
|
|
|
5430
6247
|
`Generated ${(/* @__PURE__ */ new Date()).toISOString()} from the governed ${input.report} report contract.`
|
|
5431
6248
|
].join("\n");
|
|
5432
6249
|
}
|
|
5433
|
-
const id =
|
|
6250
|
+
const id = randomUUID2();
|
|
5434
6251
|
const inserted = await getAnalyticsPool().query(
|
|
5435
6252
|
`INSERT INTO analytics_exports(id, site_id, requested_by_user_id, idempotency_key, report, format, filters, content)
|
|
5436
6253
|
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8)
|
|
@@ -5465,6 +6282,13 @@ async function createAnalyticsExport(input) {
|
|
|
5465
6282
|
}
|
|
5466
6283
|
|
|
5467
6284
|
export {
|
|
6285
|
+
encryptAnalyticsRestricted,
|
|
6286
|
+
decryptAnalyticsRestricted,
|
|
6287
|
+
analyticsIdentityHmac,
|
|
6288
|
+
signAnalyticsWebhook,
|
|
6289
|
+
verifyAnalyticsWebhookSignature,
|
|
6290
|
+
AnalyticsIdentityProfileError,
|
|
6291
|
+
resolveAnalyticsMaskedIdentityProfile,
|
|
5468
6292
|
AnalyticsProviderRegistryError,
|
|
5469
6293
|
getAnalyticsProviderDefinition,
|
|
5470
6294
|
assertConnectionSupportsProvider,
|
|
@@ -5492,6 +6316,10 @@ export {
|
|
|
5492
6316
|
listAnalyticsHostGroups,
|
|
5493
6317
|
prepareAnalyticsLinkerIssue,
|
|
5494
6318
|
redeemAnalyticsLinkerRecord,
|
|
6319
|
+
analyticsIdentityResolutionAllowed,
|
|
6320
|
+
analyticsIdentityPromotionAllowed,
|
|
6321
|
+
projectAnalyticsPixelEventConsent,
|
|
6322
|
+
resolveAnalyticsConfirmedActivationIdentity,
|
|
5495
6323
|
MAX_ENGAGED_MS,
|
|
5496
6324
|
ENGAGED_SESSION_MS,
|
|
5497
6325
|
normalizeAnalyticsPath,
|
|
@@ -5520,7 +6348,10 @@ export {
|
|
|
5520
6348
|
recordAnalyticsFormSubmission,
|
|
5521
6349
|
sanitizeClickIds,
|
|
5522
6350
|
identityHmac,
|
|
6351
|
+
backfillAnalyticsConfirmedHistory,
|
|
6352
|
+
linkAnalyticsIdentityInTransaction,
|
|
5523
6353
|
linkAnalyticsFormIdentity,
|
|
6354
|
+
enrichAnalyticsExistingCrmIdentity,
|
|
5524
6355
|
listAnalyticsPeople,
|
|
5525
6356
|
getAnalyticsPersonJourney,
|
|
5526
6357
|
createAnalyticsCrmImport,
|