mcp-scraper 0.74.0 → 0.75.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 +29 -1
- package/README.md +2 -2
- package/dist/{analytics-repository-22MAFYHS.js → analytics-repository-AUTJK4HK.js} +3 -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-BCA2OU5G.js → chunk-45AZ5CR2.js} +2 -2
- package/dist/{chunk-EQJRPDUS.js → chunk-6W4ADSWE.js} +2 -2
- package/dist/{chunk-RV2D27R4.js → chunk-D2O7V2CT.js} +1 -1
- package/dist/{chunk-S45DGELW.js → chunk-GLVWJCNU.js} +1 -1
- package/dist/{chunk-52PVP5P2.js → chunk-IBQNOZ7E.js} +1050 -171
- package/dist/{chunk-O5IRADYV.js → chunk-IUEELMHS.js} +1 -1
- package/dist/{chunk-IBJAMD4I.js → chunk-VFG5P77C.js} +409 -92
- package/dist/{extract-bundle-2V22S7EU.js → extract-bundle-L2GGQCR6.js} +2 -2
- package/dist/{gmail-service-JHL4PQFN.js → gmail-service-E52EE4GX.js} +2 -2
- package/dist/{server-PMHKIZU7.js → server-ZJUMRVCT.js} +2765 -983
- package/dist/{site-extract-repository-DUAEAU6A.js → site-extract-repository-B47JB62U.js} +2 -2
- package/dist/{worker-CBK5DWEE.js → worker-7ASMH3MT.js} +1 -1
- package/package.json +1 -1
|
@@ -438,7 +438,7 @@ function buildGoogleDeliveryRequest(destinationId, payload) {
|
|
|
438
438
|
);
|
|
439
439
|
}
|
|
440
440
|
const valueMinor = typeof payload.valueMinor === "number" && Number.isSafeInteger(payload.valueMinor) && payload.valueMinor >= 0 ? payload.valueMinor : void 0;
|
|
441
|
-
const
|
|
441
|
+
const currency2 = boundedString(payload.currency, 3)?.toUpperCase();
|
|
442
442
|
const actionSource = boundedString(payload.actionSource, 64);
|
|
443
443
|
const consent = googleConsent(payload);
|
|
444
444
|
return {
|
|
@@ -454,7 +454,7 @@ function buildGoogleDeliveryRequest(destinationId, payload) {
|
|
|
454
454
|
...wbraid ? { wbraid } : {},
|
|
455
455
|
...emailSha256 ? { emailSha256: [emailSha256] } : {},
|
|
456
456
|
...phoneSha256 ? { phoneSha256: [phoneSha256] } : {},
|
|
457
|
-
...valueMinor !== void 0 &&
|
|
457
|
+
...valueMinor !== void 0 && currency2 ? { conversionValue: valueMinor / 100, currency: currency2 } : {},
|
|
458
458
|
...consent ? { consent } : {}
|
|
459
459
|
}],
|
|
460
460
|
...consent ? { consent } : {},
|
|
@@ -491,20 +491,26 @@ function metaUserData(payload) {
|
|
|
491
491
|
function buildMetaDeliveryRequest(destinationId, eventName, payload) {
|
|
492
492
|
const actionSource = boundedString(payload.actionSource, 64);
|
|
493
493
|
const valueMinor = typeof payload.valueMinor === "number" && Number.isSafeInteger(payload.valueMinor) ? payload.valueMinor : void 0;
|
|
494
|
-
const
|
|
494
|
+
const currency2 = boundedString(payload.currency, 3)?.toUpperCase();
|
|
495
495
|
const orderId = boundedString(payload.orderId, 240);
|
|
496
496
|
const customData = {
|
|
497
497
|
...valueMinor !== void 0 && valueMinor >= 0 ? { value: valueMinor / 100 } : {},
|
|
498
|
-
...
|
|
498
|
+
...currency2 ? { currency: currency2 } : {},
|
|
499
499
|
...orderId ? { order_id: orderId } : {}
|
|
500
500
|
};
|
|
501
501
|
const eventId = boundedString(payload.eventId, 255);
|
|
502
|
+
if (!eventId) {
|
|
503
|
+
throw new AnalyticsProviderRegistryError(
|
|
504
|
+
"analytics_provider_destination_required",
|
|
505
|
+
"Meta requires the stable X-Ray conversion ID for event deduplication."
|
|
506
|
+
);
|
|
507
|
+
}
|
|
502
508
|
const eventSourceUrl = boundedString(payload.sourceUrl);
|
|
503
509
|
const event = {
|
|
504
510
|
eventName,
|
|
505
511
|
eventTime: metaEventTime(payload.eventTime),
|
|
506
512
|
actionSource: actionSource && META_ACTION_SOURCES.has(actionSource) ? actionSource : "system_generated",
|
|
507
|
-
|
|
513
|
+
eventId,
|
|
508
514
|
...eventSourceUrl ? { eventSourceUrl } : {},
|
|
509
515
|
userData: metaUserData(payload),
|
|
510
516
|
...Object.keys(customData).length ? { customData } : {}
|
|
@@ -555,7 +561,7 @@ function buildTikTokDeliveryRequest(destinationId, eventName, payload) {
|
|
|
555
561
|
}
|
|
556
562
|
const eventTime = Math.trunc(eventTimeMilliseconds(payload.eventTime, "TikTok") / 1e3);
|
|
557
563
|
const valueMinor = typeof payload.valueMinor === "number" && Number.isSafeInteger(payload.valueMinor) && payload.valueMinor >= 0 ? payload.valueMinor : void 0;
|
|
558
|
-
const
|
|
564
|
+
const currency2 = boundedString(payload.currency, 3)?.toUpperCase();
|
|
559
565
|
const sourceUrl = boundedString(payload.sourceUrl, 2048);
|
|
560
566
|
const referrer = boundedString(payload.referrer, 2048);
|
|
561
567
|
const orderId = boundedString(payload.orderId, 255);
|
|
@@ -568,7 +574,7 @@ function buildTikTokDeliveryRequest(destinationId, eventName, payload) {
|
|
|
568
574
|
eventTime,
|
|
569
575
|
userData,
|
|
570
576
|
...sourceUrl ? { page: { url: sourceUrl, ...referrer ? { referrer } : {} } } : {},
|
|
571
|
-
...valueMinor !== void 0 &&
|
|
577
|
+
...valueMinor !== void 0 && currency2 ? { properties: { value: valueMinor / 100, currency: currency2, ...orderId ? { orderId } : {} } } : orderId ? { properties: { orderId } } : {}
|
|
572
578
|
}],
|
|
573
579
|
...testEventCode ? { testEventCode } : {},
|
|
574
580
|
schemaValidationOnly: false
|
|
@@ -630,7 +636,7 @@ function buildRedditDeliveryRequest(destinationId, eventName, payload) {
|
|
|
630
636
|
const normalizedName = eventName.trim().toLowerCase().replace(/[ -]+/g, "_");
|
|
631
637
|
const trackingType = REDDIT_TRACKING_TYPES.get(normalizedName) ?? "CUSTOM";
|
|
632
638
|
const valueMinor = typeof payload.valueMinor === "number" && Number.isSafeInteger(payload.valueMinor) && payload.valueMinor >= 0 ? payload.valueMinor : void 0;
|
|
633
|
-
const
|
|
639
|
+
const currency2 = boundedString(payload.currency, 3)?.toUpperCase();
|
|
634
640
|
const itemCount = typeof payload.itemCount === "number" && Number.isSafeInteger(payload.itemCount) && payload.itemCount >= 0 ? payload.itemCount : void 0;
|
|
635
641
|
const sourceUrl = boundedString(payload.sourceUrl, 2048);
|
|
636
642
|
const testId = boundedString(payload.testId, 255);
|
|
@@ -644,7 +650,7 @@ function buildRedditDeliveryRequest(destinationId, eventName, payload) {
|
|
|
644
650
|
...sourceUrl ? { eventSourceUrl: sourceUrl } : {},
|
|
645
651
|
trackingType,
|
|
646
652
|
...trackingType === "CUSTOM" ? { customEventName: eventName.slice(0, 64) } : {},
|
|
647
|
-
...valueMinor !== void 0 &&
|
|
653
|
+
...valueMinor !== void 0 && currency2 ? { value: valueMinor / 100, currency: currency2 } : {},
|
|
648
654
|
...itemCount !== void 0 ? { itemCount } : {},
|
|
649
655
|
user
|
|
650
656
|
}],
|
|
@@ -722,13 +728,82 @@ function assertProviderDestination(platform, destinationId) {
|
|
|
722
728
|
}
|
|
723
729
|
return trimmed;
|
|
724
730
|
}
|
|
725
|
-
function
|
|
731
|
+
function validMappingName(value, max) {
|
|
732
|
+
return typeof value === "string" && value.trim().length > 0 && value.trim().length <= max && /^[a-zA-Z][a-zA-Z0-9_.: -]*$/.test(value.trim());
|
|
733
|
+
}
|
|
734
|
+
function normalizeAnalyticsActivationEventMapping(eventMapping) {
|
|
726
735
|
if (eventMapping && Object.prototype.hasOwnProperty.call(eventMapping, "__tool")) {
|
|
727
736
|
throw new AnalyticsProviderRegistryError(
|
|
728
737
|
"analytics_provider_action_unavailable",
|
|
729
738
|
"Activation action selection is managed by X-Ray."
|
|
730
739
|
);
|
|
731
740
|
}
|
|
741
|
+
if (!eventMapping) return { schemaVersion: 1, mappings: [] };
|
|
742
|
+
if ("schemaVersion" in eventMapping || "mappings" in eventMapping) {
|
|
743
|
+
const candidate = eventMapping;
|
|
744
|
+
if (candidate.schemaVersion !== 1 || !Array.isArray(candidate.mappings) || candidate.mappings.length > 200) {
|
|
745
|
+
throw new AnalyticsProviderRegistryError(
|
|
746
|
+
"analytics_provider_destination_required",
|
|
747
|
+
"Activation mappings must use the supported versioned mapping contract."
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
const mappings2 = candidate.mappings.map((row) => {
|
|
751
|
+
if (!row || typeof row !== "object" || !validMappingName(row.source, 160) || !validMappingName(row.providerEvent, 160) || typeof row.enabled !== "boolean" || !["primary", "observation"].includes(row.role) || !["none", "event", "fixed"].includes(row.valueMode)) {
|
|
752
|
+
throw new AnalyticsProviderRegistryError(
|
|
753
|
+
"analytics_provider_destination_required",
|
|
754
|
+
"Activation mapping contains an unsupported event, role, or value mode."
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
const currency2 = row.currency?.trim().toUpperCase();
|
|
758
|
+
if (row.valueMode === "fixed" && (!Number.isFinite(row.fixedValue) || Number(row.fixedValue) < 0 || Number(row.fixedValue) > 1e9 || !currency2 || !/^[A-Z]{3}$/.test(currency2))) {
|
|
759
|
+
throw new AnalyticsProviderRegistryError(
|
|
760
|
+
"analytics_provider_destination_required",
|
|
761
|
+
"Fixed activation values require a non-negative value and ISO currency."
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
return {
|
|
765
|
+
source: row.source.trim(),
|
|
766
|
+
providerEvent: row.providerEvent.trim(),
|
|
767
|
+
enabled: row.enabled,
|
|
768
|
+
role: row.role,
|
|
769
|
+
valueMode: row.valueMode,
|
|
770
|
+
...row.valueMode === "fixed" ? { fixedValue: Number(row.fixedValue), currency: currency2 } : {}
|
|
771
|
+
};
|
|
772
|
+
});
|
|
773
|
+
const enabled = mappings2.filter((row) => row.enabled);
|
|
774
|
+
if (enabled.length && enabled.filter((row) => row.role === "primary").length !== 1) {
|
|
775
|
+
throw new AnalyticsProviderRegistryError(
|
|
776
|
+
"analytics_provider_destination_required",
|
|
777
|
+
"Enabled activation mappings require exactly one primary optimization event."
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
if (new Set(mappings2.map((row) => row.source.toLowerCase())).size !== mappings2.length) {
|
|
781
|
+
throw new AnalyticsProviderRegistryError(
|
|
782
|
+
"analytics_provider_destination_required",
|
|
783
|
+
"Each X-Ray event may be mapped only once per destination."
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
return { schemaVersion: 1, mappings: mappings2 };
|
|
787
|
+
}
|
|
788
|
+
const mappings = Object.entries(eventMapping).map(([source, providerEvent], index) => {
|
|
789
|
+
if (!validMappingName(source, 160) || !validMappingName(providerEvent, 160)) {
|
|
790
|
+
throw new AnalyticsProviderRegistryError(
|
|
791
|
+
"analytics_provider_destination_required",
|
|
792
|
+
"Activation mapping contains an invalid event name."
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
return {
|
|
796
|
+
source: source.trim(),
|
|
797
|
+
providerEvent: providerEvent.trim(),
|
|
798
|
+
enabled: true,
|
|
799
|
+
role: index === 0 ? "primary" : "observation",
|
|
800
|
+
valueMode: "event"
|
|
801
|
+
};
|
|
802
|
+
});
|
|
803
|
+
return { schemaVersion: 1, mappings };
|
|
804
|
+
}
|
|
805
|
+
function assertSafeAnalyticsEventMapping(eventMapping) {
|
|
806
|
+
normalizeAnalyticsActivationEventMapping(eventMapping);
|
|
732
807
|
}
|
|
733
808
|
function assertConnectionSupportsProvider(connection, platform) {
|
|
734
809
|
const definition = getAnalyticsProviderDefinition(platform);
|
|
@@ -746,22 +821,42 @@ function assertConnectionSupportsProvider(connection, platform) {
|
|
|
746
821
|
}
|
|
747
822
|
function mappedEventName(input) {
|
|
748
823
|
const canonical = String(input.payload.eventName || "conversion");
|
|
749
|
-
|
|
824
|
+
const mapping = normalizeAnalyticsActivationEventMapping(input.eventMapping).mappings.find((row) => row.enabled && row.source === canonical);
|
|
825
|
+
return mapping?.providerEvent || canonical;
|
|
826
|
+
}
|
|
827
|
+
function analyticsActivationMappingEligible(eventMapping, sourceEvent) {
|
|
828
|
+
const normalized = normalizeAnalyticsActivationEventMapping(eventMapping);
|
|
829
|
+
return normalized.mappings.length === 0 || normalized.mappings.some((row) => row.enabled && row.source === sourceEvent);
|
|
830
|
+
}
|
|
831
|
+
function mappedPayload(input) {
|
|
832
|
+
const canonical = String(input.payload.eventName || "conversion");
|
|
833
|
+
const mapping = normalizeAnalyticsActivationEventMapping(input.eventMapping).mappings.find((row) => row.enabled && row.source === canonical);
|
|
834
|
+
if (!mapping || mapping.valueMode === "event") return input.payload;
|
|
835
|
+
const payload = { ...input.payload };
|
|
836
|
+
if (mapping.valueMode === "none") {
|
|
837
|
+
delete payload.valueMinor;
|
|
838
|
+
delete payload.currency;
|
|
839
|
+
return payload;
|
|
840
|
+
}
|
|
841
|
+
payload.valueMinor = Math.round((mapping.fixedValue ?? 0) * 100);
|
|
842
|
+
payload.currency = mapping.currency;
|
|
843
|
+
return payload;
|
|
750
844
|
}
|
|
751
845
|
function buildProviderDeliveryRequest(input) {
|
|
752
846
|
const destinationId = assertProviderDestination(input.platform, input.destinationId);
|
|
753
847
|
assertSafeAnalyticsEventMapping(input.eventMapping);
|
|
754
848
|
const eventName = mappedEventName(input);
|
|
849
|
+
const payload = mappedPayload(input);
|
|
755
850
|
if (input.platform === "meta") {
|
|
756
|
-
return buildMetaDeliveryRequest(destinationId, eventName,
|
|
851
|
+
return buildMetaDeliveryRequest(destinationId, eventName, payload);
|
|
757
852
|
}
|
|
758
853
|
if (input.platform === "google") {
|
|
759
|
-
return buildGoogleDeliveryRequest(destinationId,
|
|
854
|
+
return buildGoogleDeliveryRequest(destinationId, payload);
|
|
760
855
|
}
|
|
761
856
|
if (input.platform === "tiktok") {
|
|
762
|
-
return buildTikTokDeliveryRequest(destinationId, eventName,
|
|
857
|
+
return buildTikTokDeliveryRequest(destinationId, eventName, payload);
|
|
763
858
|
}
|
|
764
|
-
return buildRedditDeliveryRequest(destinationId, eventName,
|
|
859
|
+
return buildRedditDeliveryRequest(destinationId, eventName, payload);
|
|
765
860
|
}
|
|
766
861
|
function buildProviderTestRequest(input) {
|
|
767
862
|
const definition = getAnalyticsProviderDefinition(input.platform);
|
|
@@ -813,14 +908,502 @@ function normalizeProviderReceipt(platform, receipt, stableEventId) {
|
|
|
813
908
|
const eventsReceived = typeof record.eventsReceived === "number" && Number.isFinite(record.eventsReceived) ? Math.max(0, Math.trunc(record.eventsReceived)) : void 0;
|
|
814
909
|
const eventsRejected = typeof record.eventsRejected === "number" && Number.isFinite(record.eventsRejected) ? Math.max(0, Math.trunc(record.eventsRejected)) : void 0;
|
|
815
910
|
const partiallyAccepted = record.partiallyAccepted === true;
|
|
911
|
+
const providerErrors = Array.isArray(record.errors) ? record.errors : [];
|
|
912
|
+
const googlePartialFailure = platform === "google" && (/PARTIAL_SUCCESS/i.test(String(record.status ?? "")) || partiallyAccepted) && (providerErrors.length > 0 || (eventsRejected ?? 0) > 0);
|
|
913
|
+
const googlePending = platform === "google" && record.acceptedForProcessing === true && Boolean(optionalString(record, "requestId")) && !googlePartialFailure;
|
|
914
|
+
const generallyAccepted = record.accepted === true || record.success === true || record.providerAccepted === true || (eventsReceived ?? 0) > 0;
|
|
816
915
|
return {
|
|
817
|
-
accepted: record.schemaValidationOnly !== true &&
|
|
916
|
+
accepted: record.schemaValidationOnly !== true && !googlePartialFailure && (platform === "google" ? generallyAccepted && !googlePending : generallyAccepted),
|
|
917
|
+
...googlePending ? { pending: true } : {},
|
|
818
918
|
...partiallyAccepted ? { partiallyAccepted: true } : {},
|
|
819
|
-
...optionalString(record, "requestId", "receiptId", "id") ? { requestId: optionalString(record, "requestId", "receiptId", "id") } : {},
|
|
919
|
+
...optionalString(record, "requestId", "receiptId", "fbtraceId", "fbtrace_id", "id") ? { requestId: optionalString(record, "requestId", "receiptId", "fbtraceId", "fbtrace_id", "id") } : {},
|
|
820
920
|
...optionalString(record, "eventId", "conversionId") || boundedString(stableEventId, 255) ? { eventId: optionalString(record, "eventId", "conversionId") || boundedString(stableEventId, 255) } : {},
|
|
821
921
|
...eventsReceived !== void 0 ? { eventsReceived } : {},
|
|
822
922
|
...eventsRejected !== void 0 ? { eventsRejected } : {},
|
|
823
|
-
warnings
|
|
923
|
+
warnings: [...warnings, ...providerErrors.map((value) => {
|
|
924
|
+
const error = recordValue(value);
|
|
925
|
+
return [boundedString(error.reason, 200), boundedString(error.field, 200), boundedString(error.description, 500)].filter(Boolean).join(": ") || "provider_error";
|
|
926
|
+
})].slice(0, 50)
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// src/api/analytics-attribution-reports.ts
|
|
931
|
+
var CREDIT_SCALE = 1e6;
|
|
932
|
+
var ANALYTICS_ATTRIBUTION_MODELS = [
|
|
933
|
+
"first_touch",
|
|
934
|
+
"last_touch",
|
|
935
|
+
"last_non_direct",
|
|
936
|
+
"linear",
|
|
937
|
+
"time_decay",
|
|
938
|
+
"position_based",
|
|
939
|
+
"custom_weighted"
|
|
940
|
+
];
|
|
941
|
+
var ANALYTICS_ATTRIBUTION_WINDOWS = [7, 14, 30, 60, 90, 180, 365, "lifetime"];
|
|
942
|
+
var AnalyticsAttributionReportError = class extends Error {
|
|
943
|
+
constructor(code, message) {
|
|
944
|
+
super(message);
|
|
945
|
+
this.code = code;
|
|
946
|
+
this.name = "AnalyticsAttributionReportError";
|
|
947
|
+
}
|
|
948
|
+
code;
|
|
949
|
+
};
|
|
950
|
+
function normalizeAnalyticsAttributionModel(model) {
|
|
951
|
+
const normalized = model === "position_40_20_40" ? "position_based" : model;
|
|
952
|
+
if (!ANALYTICS_ATTRIBUTION_MODELS.includes(normalized)) {
|
|
953
|
+
throw new AnalyticsAttributionReportError("invalid_model", `Unsupported attribution model: ${model}.`);
|
|
954
|
+
}
|
|
955
|
+
return normalized;
|
|
956
|
+
}
|
|
957
|
+
function instant(value, field) {
|
|
958
|
+
const milliseconds = value instanceof Date ? value.getTime() : Date.parse(value);
|
|
959
|
+
if (!Number.isFinite(milliseconds)) throw new AnalyticsAttributionReportError("invalid_timestamp", `${field} must be a valid timestamp.`);
|
|
960
|
+
return milliseconds;
|
|
961
|
+
}
|
|
962
|
+
function minorUnits(value, field) {
|
|
963
|
+
if (value == null) return 0n;
|
|
964
|
+
try {
|
|
965
|
+
const parsed = typeof value === "bigint" ? value : BigInt(value);
|
|
966
|
+
if (parsed < 0n) throw new Error("negative");
|
|
967
|
+
return parsed;
|
|
968
|
+
} catch {
|
|
969
|
+
throw new AnalyticsAttributionReportError("invalid_minor_units", `${field} must be a non-negative integer.`);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
function currency(value) {
|
|
973
|
+
if (value == null || value.trim() === "") return null;
|
|
974
|
+
const normalized = value.trim().toUpperCase();
|
|
975
|
+
if (!/^[A-Z]{3}$/.test(normalized)) throw new AnalyticsAttributionReportError("invalid_currency", `Invalid currency ${value}.`);
|
|
976
|
+
return normalized;
|
|
977
|
+
}
|
|
978
|
+
function isDirect(touch) {
|
|
979
|
+
return touch.touchKind === "direct" || touch.source?.trim().toLowerCase() === "(direct)" && ["(none)", "none", ""].includes(touch.medium?.trim().toLowerCase() ?? "");
|
|
980
|
+
}
|
|
981
|
+
function windowForTouch(touch, config) {
|
|
982
|
+
return touch.touchKind === "view" ? config.viewWindow ?? 90 : config.clickWindow ?? 90;
|
|
983
|
+
}
|
|
984
|
+
function eligibleTouches(touches, conversion, config) {
|
|
985
|
+
const conversionTime = instant(conversion.occurredAt, "conversion.occurredAt");
|
|
986
|
+
return touches.filter((touch) => touch.subjectRef === conversion.subjectRef && touch.journeyTier === conversion.journeyTier).filter((touch) => {
|
|
987
|
+
const touchTime = instant(touch.occurredAt, "touch.occurredAt");
|
|
988
|
+
if (touchTime > conversionTime) return false;
|
|
989
|
+
const window = windowForTouch(touch, config);
|
|
990
|
+
return window === "lifetime" || conversionTime - touchTime <= window * 864e5;
|
|
991
|
+
}).sort((left, right) => instant(left.occurredAt, "touch.occurredAt") - instant(right.occurredAt, "touch.occurredAt") || left.touchId.localeCompare(right.touchId));
|
|
992
|
+
}
|
|
993
|
+
function customWeight(touch, weights) {
|
|
994
|
+
const selectors = [
|
|
995
|
+
`touch:${touch.touchId}`,
|
|
996
|
+
touch.adId ? `ad:${touch.adId}` : null,
|
|
997
|
+
touch.adSetId ? `ad_set:${touch.adSetId}` : null,
|
|
998
|
+
touch.campaignId ? `campaign:${touch.campaignId}` : null,
|
|
999
|
+
touch.platform ? `platform:${touch.platform}` : null,
|
|
1000
|
+
touch.source ? `source:${touch.source}` : null,
|
|
1001
|
+
touch.medium ? `medium:${touch.medium}` : null,
|
|
1002
|
+
`touch_kind:${touch.touchKind}`,
|
|
1003
|
+
"default"
|
|
1004
|
+
];
|
|
1005
|
+
for (const selector of selectors) if (selector && weights[selector] != null) return weights[selector];
|
|
1006
|
+
return 0;
|
|
1007
|
+
}
|
|
1008
|
+
function rawWeights(touches, config) {
|
|
1009
|
+
if (touches.length === 0) return [];
|
|
1010
|
+
if (config.model === "first_touch") return touches.map((_, index) => index === 0 ? 1 : 0);
|
|
1011
|
+
if (config.model === "last_touch") return touches.map((_, index) => index === touches.length - 1 ? 1 : 0);
|
|
1012
|
+
if (config.model === "last_non_direct") {
|
|
1013
|
+
let index = -1;
|
|
1014
|
+
for (let cursor = touches.length - 1; cursor >= 0; cursor -= 1) {
|
|
1015
|
+
if (!isDirect(touches[cursor])) {
|
|
1016
|
+
index = cursor;
|
|
1017
|
+
break;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
if (index === -1) index = touches.length - 1;
|
|
1021
|
+
return touches.map((_, cursor) => cursor === index ? 1 : 0);
|
|
1022
|
+
}
|
|
1023
|
+
if (config.model === "linear") return touches.map(() => 1);
|
|
1024
|
+
if (config.model === "time_decay") {
|
|
1025
|
+
const halfLife = config.timeDecayHalfLifeDays ?? 7;
|
|
1026
|
+
if (!Number.isFinite(halfLife) || halfLife <= 0) throw new AnalyticsAttributionReportError("invalid_half_life", "Time-decay half-life must be positive.");
|
|
1027
|
+
const newestTouchTime = Math.max(...touches.map((touch) => instant(touch.occurredAt, "touch.occurredAt")));
|
|
1028
|
+
return touches.map((touch) => Math.pow(0.5, (newestTouchTime - instant(touch.occurredAt, "touch.occurredAt")) / 864e5 / halfLife));
|
|
1029
|
+
}
|
|
1030
|
+
if (config.model === "position_based" || config.model === "position_40_20_40") {
|
|
1031
|
+
if (touches.length === 1) return [1];
|
|
1032
|
+
if (touches.length === 2) return [0.5, 0.5];
|
|
1033
|
+
return touches.map((_, index) => index === 0 || index === touches.length - 1 ? 0.4 : 0.2 / (touches.length - 2));
|
|
1034
|
+
}
|
|
1035
|
+
const weights = config.customWeights;
|
|
1036
|
+
if (!weights || Object.keys(weights).length === 0) throw new AnalyticsAttributionReportError("custom_weights_required", "Custom-weighted attribution requires at least one selector.");
|
|
1037
|
+
for (const [selector, weight] of Object.entries(weights)) {
|
|
1038
|
+
if (!selector.trim() || !Number.isFinite(weight) || weight < 0) throw new AnalyticsAttributionReportError("invalid_custom_weight", `Invalid custom weight for ${selector || "(empty)"}.`);
|
|
1039
|
+
}
|
|
1040
|
+
const positionalKeys = /* @__PURE__ */ new Set(["first", "middle", "last"]);
|
|
1041
|
+
if (Object.keys(weights).every((key) => positionalKeys.has(key))) {
|
|
1042
|
+
const first = weights.first ?? 0;
|
|
1043
|
+
const middle = weights.middle ?? 0;
|
|
1044
|
+
const last = weights.last ?? 0;
|
|
1045
|
+
if (touches.length === 1) return [first + middle + last];
|
|
1046
|
+
if (touches.length === 2) return [first + middle / 2, last + middle / 2];
|
|
1047
|
+
return touches.map((_, index) => index === 0 ? first : index === touches.length - 1 ? last : middle / (touches.length - 2));
|
|
1048
|
+
}
|
|
1049
|
+
return touches.map((touch) => customWeight(touch, weights));
|
|
1050
|
+
}
|
|
1051
|
+
function allocateMicros(weights) {
|
|
1052
|
+
if (weights.length === 0) return [];
|
|
1053
|
+
const total = weights.reduce((sum, value) => sum + value, 0);
|
|
1054
|
+
if (!Number.isFinite(total) || total <= 0) throw new AnalyticsAttributionReportError("zero_attribution_weight", "Eligible touches must have a positive total weight.");
|
|
1055
|
+
const exact = weights.map((value) => value / total * CREDIT_SCALE);
|
|
1056
|
+
const floors = exact.map((value) => Math.floor(value));
|
|
1057
|
+
let remainder = CREDIT_SCALE - floors.reduce((sum, value) => sum + value, 0);
|
|
1058
|
+
const order = exact.map((value, index) => ({ index, fraction: value - floors[index] })).sort((left, right) => right.fraction - left.fraction || left.index - right.index);
|
|
1059
|
+
for (let cursor = 0; cursor < remainder; cursor += 1) floors[order[cursor % order.length].index] += 1;
|
|
1060
|
+
return floors;
|
|
1061
|
+
}
|
|
1062
|
+
function allocateMinor(total, micros) {
|
|
1063
|
+
if (micros.length === 0) return [];
|
|
1064
|
+
const scale = BigInt(CREDIT_SCALE);
|
|
1065
|
+
const floors = micros.map((value) => total * BigInt(value) / scale);
|
|
1066
|
+
let remainder = total - floors.reduce((sum, value) => sum + value, 0n);
|
|
1067
|
+
const order = micros.map((value, index) => ({ index, remainder: total * BigInt(value) % scale })).sort((left, right) => left.remainder === right.remainder ? left.index - right.index : left.remainder > right.remainder ? -1 : 1);
|
|
1068
|
+
let cursor = 0;
|
|
1069
|
+
while (remainder > 0n) {
|
|
1070
|
+
floors[order[cursor % order.length].index] += 1n;
|
|
1071
|
+
remainder -= 1n;
|
|
1072
|
+
cursor += 1;
|
|
1073
|
+
}
|
|
1074
|
+
return floors;
|
|
1075
|
+
}
|
|
1076
|
+
function dimensionsAtGranularity(source, granularity) {
|
|
1077
|
+
const output = { platform: source.platform?.trim() || null };
|
|
1078
|
+
if (granularity === "platform") return output;
|
|
1079
|
+
output.accountId = source.accountId?.trim() || null;
|
|
1080
|
+
if (granularity === "account") return output;
|
|
1081
|
+
output.campaignId = source.campaignId?.trim() || null;
|
|
1082
|
+
output.campaignName = source.campaignName?.trim() || null;
|
|
1083
|
+
if (granularity === "campaign") return output;
|
|
1084
|
+
output.adSetId = source.adSetId?.trim() || null;
|
|
1085
|
+
output.adSetName = source.adSetName?.trim() || null;
|
|
1086
|
+
if (granularity === "ad_set") return output;
|
|
1087
|
+
output.adId = source.adId?.trim() || null;
|
|
1088
|
+
output.adName = source.adName?.trim() || null;
|
|
1089
|
+
return output;
|
|
1090
|
+
}
|
|
1091
|
+
function dimensionKey(value, granularity) {
|
|
1092
|
+
const dimensions = dimensionsAtGranularity(value, granularity);
|
|
1093
|
+
return [dimensions.platform, dimensions.accountId, dimensions.campaignId, dimensions.adSetId, dimensions.adId].filter((_, index) => index <= ["platform", "account", "campaign", "ad_set", "ad"].indexOf(granularity)).map((item) => item || "(missing)").join("|");
|
|
1094
|
+
}
|
|
1095
|
+
function missingDimension(value, granularity) {
|
|
1096
|
+
const dimensions = dimensionsAtGranularity(value, granularity);
|
|
1097
|
+
if (!dimensions.platform) return true;
|
|
1098
|
+
if (granularity !== "platform" && !dimensions.accountId) return true;
|
|
1099
|
+
if (["campaign", "ad_set", "ad"].includes(granularity) && !dimensions.campaignId) return true;
|
|
1100
|
+
if (["ad_set", "ad"].includes(granularity) && !dimensions.adSetId) return true;
|
|
1101
|
+
return granularity === "ad" && !dimensions.adId;
|
|
1102
|
+
}
|
|
1103
|
+
function makeCredits(conversions, touches, config) {
|
|
1104
|
+
const credits = [];
|
|
1105
|
+
for (const conversion of conversions) {
|
|
1106
|
+
const eligible = eligibleTouches(touches, conversion, config);
|
|
1107
|
+
const conversionCurrency = currency(conversion.currency);
|
|
1108
|
+
const value = minorUnits(conversion.valueMinor, "conversion.valueMinor");
|
|
1109
|
+
if (eligible.length === 0) {
|
|
1110
|
+
credits.push({
|
|
1111
|
+
conversionId: conversion.conversionId,
|
|
1112
|
+
touchId: null,
|
|
1113
|
+
subjectRef: conversion.subjectRef,
|
|
1114
|
+
journeyTier: conversion.journeyTier,
|
|
1115
|
+
occurredAt: new Date(instant(conversion.occurredAt, "conversion.occurredAt")).toISOString(),
|
|
1116
|
+
outcome: conversion.outcome,
|
|
1117
|
+
creditMicros: CREDIT_SCALE,
|
|
1118
|
+
creditedValueMinor: value.toString(),
|
|
1119
|
+
currency: conversionCurrency,
|
|
1120
|
+
unattributed: true
|
|
1121
|
+
});
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1124
|
+
const micros = allocateMicros(rawWeights(eligible, config));
|
|
1125
|
+
const values = allocateMinor(value, micros);
|
|
1126
|
+
eligible.forEach((touch, index) => {
|
|
1127
|
+
if (micros[index] === 0) return;
|
|
1128
|
+
credits.push({
|
|
1129
|
+
...dimensionsAtGranularity(touch, "ad"),
|
|
1130
|
+
conversionId: conversion.conversionId,
|
|
1131
|
+
touchId: touch.touchId,
|
|
1132
|
+
subjectRef: conversion.subjectRef,
|
|
1133
|
+
journeyTier: conversion.journeyTier,
|
|
1134
|
+
occurredAt: new Date(instant(conversion.occurredAt, "conversion.occurredAt")).toISOString(),
|
|
1135
|
+
outcome: conversion.outcome,
|
|
1136
|
+
creditMicros: micros[index],
|
|
1137
|
+
creditedValueMinor: values[index].toString(),
|
|
1138
|
+
currency: conversionCurrency,
|
|
1139
|
+
unattributed: false
|
|
1140
|
+
});
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
return credits;
|
|
1144
|
+
}
|
|
1145
|
+
function efficiencyRows(credits, spendFacts, granularity) {
|
|
1146
|
+
const rows = /* @__PURE__ */ new Map();
|
|
1147
|
+
const rowFor = (key, dimensions, unattributed) => {
|
|
1148
|
+
let row = rows.get(key);
|
|
1149
|
+
if (!row) {
|
|
1150
|
+
row = { dimensions, unattributed, leadMicros: 0, qualifiedMicros: 0, conversionMicros: 0, customerMicros: 0, revenueMinor: 0n, spendMinor: 0n, currencies: /* @__PURE__ */ new Set(), spendFacts: 0, matchedSpendFacts: 0, missingDimension: missingDimension(dimensions, granularity), hasCredits: false };
|
|
1151
|
+
rows.set(key, row);
|
|
1152
|
+
}
|
|
1153
|
+
return row;
|
|
1154
|
+
};
|
|
1155
|
+
let missingDimensionCredits = 0;
|
|
1156
|
+
for (const credit of credits) {
|
|
1157
|
+
const dimensions = credit.unattributed ? {} : dimensionsAtGranularity(credit, granularity);
|
|
1158
|
+
const key = credit.unattributed ? "(unattributed)" : dimensionKey(dimensions, granularity);
|
|
1159
|
+
const row = rowFor(key, dimensions, credit.unattributed);
|
|
1160
|
+
row.hasCredits = true;
|
|
1161
|
+
if (!credit.unattributed && row.missingDimension) missingDimensionCredits += 1;
|
|
1162
|
+
if (credit.outcome === "lead") row.leadMicros += credit.creditMicros;
|
|
1163
|
+
if (credit.outcome === "qualified") row.qualifiedMicros += credit.creditMicros;
|
|
1164
|
+
if (credit.outcome === "conversion") row.conversionMicros += credit.creditMicros;
|
|
1165
|
+
if (credit.outcome === "customer") row.customerMicros += credit.creditMicros;
|
|
1166
|
+
row.revenueMinor += BigInt(credit.creditedValueMinor);
|
|
1167
|
+
if (credit.currency) row.currencies.add(credit.currency);
|
|
1168
|
+
}
|
|
1169
|
+
let unmatchedSpendFactCount = 0;
|
|
1170
|
+
for (const fact of spendFacts) {
|
|
1171
|
+
const dimensions = dimensionsAtGranularity(fact, granularity);
|
|
1172
|
+
const key = dimensionKey(dimensions, granularity);
|
|
1173
|
+
const row = rowFor(key, dimensions, false);
|
|
1174
|
+
row.spendMinor += minorUnits(fact.spendMinor, "spendFact.spendMinor");
|
|
1175
|
+
row.currencies.add(currency(fact.currency));
|
|
1176
|
+
row.spendFacts += 1;
|
|
1177
|
+
if (row.hasCredits && !row.missingDimension) row.matchedSpendFacts += 1;
|
|
1178
|
+
else unmatchedSpendFactCount += 1;
|
|
1179
|
+
}
|
|
1180
|
+
const allCurrencies = /* @__PURE__ */ new Set();
|
|
1181
|
+
for (const row of rows.values()) for (const item of row.currencies) allCurrencies.add(item);
|
|
1182
|
+
if (allCurrencies.size > 1) throw new AnalyticsAttributionReportError("mixed_currencies", `Attribution reports require one currency; received ${[...allCurrencies].sort().join(", ")}.`);
|
|
1183
|
+
const result = [...rows.entries()].map(([key, row]) => {
|
|
1184
|
+
const leadCredit = row.leadMicros / CREDIT_SCALE;
|
|
1185
|
+
const qualifiedCredit = row.qualifiedMicros / CREDIT_SCALE;
|
|
1186
|
+
const conversionCredit = row.conversionMicros / CREDIT_SCALE;
|
|
1187
|
+
const customerCredit = row.customerMicros / CREDIT_SCALE;
|
|
1188
|
+
const spend = Number(row.spendMinor);
|
|
1189
|
+
const revenue = Number(row.revenueMinor);
|
|
1190
|
+
const hasSpend = row.spendFacts > 0;
|
|
1191
|
+
const safeRatio = (denominator) => hasSpend && denominator > 0 ? spend / denominator : null;
|
|
1192
|
+
return {
|
|
1193
|
+
...row.dimensions,
|
|
1194
|
+
key,
|
|
1195
|
+
unattributed: row.unattributed,
|
|
1196
|
+
leadCredit,
|
|
1197
|
+
qualifiedCredit,
|
|
1198
|
+
conversionCredit,
|
|
1199
|
+
customerCredit,
|
|
1200
|
+
spendMinor: hasSpend ? row.spendMinor.toString() : null,
|
|
1201
|
+
revenueMinor: row.hasCredits && row.currencies.size > 0 ? row.revenueMinor.toString() : null,
|
|
1202
|
+
currency: row.currencies.size === 1 ? [...row.currencies][0] : null,
|
|
1203
|
+
cplMinor: safeRatio(leadCredit),
|
|
1204
|
+
cpqlMinor: safeRatio(qualifiedCredit),
|
|
1205
|
+
cpaMinor: safeRatio(conversionCredit),
|
|
1206
|
+
cacMinor: safeRatio(customerCredit),
|
|
1207
|
+
roas: hasSpend && spend > 0 && row.hasCredits ? revenue / spend : null,
|
|
1208
|
+
coverage: { spendFacts: row.spendFacts, matchedSpendFacts: row.matchedSpendFacts, missingDimension: row.missingDimension }
|
|
1209
|
+
};
|
|
1210
|
+
});
|
|
1211
|
+
return { rows: result.sort((left, right) => left.key.localeCompare(right.key)), unmatchedSpendFactCount, missingDimensionCredits };
|
|
1212
|
+
}
|
|
1213
|
+
function validateUniqueIds(values, kind) {
|
|
1214
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1215
|
+
for (const value of values) {
|
|
1216
|
+
if (!value.id || ids.has(value.id)) throw new AnalyticsAttributionReportError(`invalid_${kind}_id`, `${kind} IDs must be present and unique.`);
|
|
1217
|
+
ids.add(value.id);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
function buildAnalyticsAttributionReport(input) {
|
|
1221
|
+
const model = normalizeAnalyticsAttributionModel(input.configuration.model);
|
|
1222
|
+
const clickWindow = input.configuration.clickWindow ?? 90;
|
|
1223
|
+
const viewWindow = input.configuration.viewWindow ?? 90;
|
|
1224
|
+
if (!ANALYTICS_ATTRIBUTION_WINDOWS.includes(clickWindow) || !ANALYTICS_ATTRIBUTION_WINDOWS.includes(viewWindow)) throw new AnalyticsAttributionReportError("invalid_window", "Attribution window must be 7, 14, 30, 60, 90, 180, 365 or lifetime.");
|
|
1225
|
+
validateUniqueIds(input.touches.map((touch) => ({ id: touch.touchId })), "touch");
|
|
1226
|
+
validateUniqueIds(input.conversions.map((conversion) => ({ id: conversion.conversionId })), "conversion");
|
|
1227
|
+
validateUniqueIds((input.spendFacts ?? []).map((fact) => ({ id: fact.spendFactId })), "spend_fact");
|
|
1228
|
+
const config = { ...input.configuration, model, clickWindow, viewWindow };
|
|
1229
|
+
const confirmedConversions = input.conversions.filter((item) => item.journeyTier === "confirmed");
|
|
1230
|
+
const candidateConversions = input.conversions.filter((item) => item.journeyTier === "candidate" && item.candidateConfidence === "high");
|
|
1231
|
+
const confirmedCredits = makeCredits(confirmedConversions, input.touches.filter((item) => item.journeyTier === "confirmed"), config);
|
|
1232
|
+
const candidateCredits = makeCredits(candidateConversions, input.touches.filter((item) => item.journeyTier === "candidate" && item.candidateConfidence === "high"), config);
|
|
1233
|
+
const granularity = input.granularity ?? "campaign";
|
|
1234
|
+
const confirmedRows = efficiencyRows(confirmedCredits, input.spendFacts ?? [], granularity);
|
|
1235
|
+
const candidateRows = efficiencyRows(candidateCredits, [], granularity);
|
|
1236
|
+
return {
|
|
1237
|
+
configuration: {
|
|
1238
|
+
id: input.configuration.id ?? `${model}:${clickWindow}:${viewWindow}`,
|
|
1239
|
+
model,
|
|
1240
|
+
clickWindow,
|
|
1241
|
+
viewWindow,
|
|
1242
|
+
timeDecayHalfLifeDays: input.configuration.timeDecayHalfLifeDays ?? 7
|
|
1243
|
+
},
|
|
1244
|
+
confirmed: {
|
|
1245
|
+
credits: confirmedCredits,
|
|
1246
|
+
rows: confirmedRows.rows,
|
|
1247
|
+
conversionCount: confirmedConversions.length,
|
|
1248
|
+
unattributedCount: new Set(confirmedCredits.filter((item) => item.unattributed).map((item) => item.conversionId)).size
|
|
1249
|
+
},
|
|
1250
|
+
candidateAssist: {
|
|
1251
|
+
credits: candidateCredits,
|
|
1252
|
+
rows: candidateRows.rows,
|
|
1253
|
+
conversionCount: candidateConversions.length,
|
|
1254
|
+
unattributedCount: new Set(candidateCredits.filter((item) => item.unattributed).map((item) => item.conversionId)).size,
|
|
1255
|
+
eligibility: "high_confidence_only"
|
|
1256
|
+
},
|
|
1257
|
+
coverage: {
|
|
1258
|
+
touchCount: input.touches.length,
|
|
1259
|
+
conversionCount: input.conversions.length,
|
|
1260
|
+
excludedCandidateConversions: input.conversions.filter((item) => item.journeyTier === "candidate" && item.candidateConfidence !== "high").length,
|
|
1261
|
+
spendFactCount: input.spendFacts?.length ?? 0,
|
|
1262
|
+
unmatchedSpendFactCount: confirmedRows.unmatchedSpendFactCount,
|
|
1263
|
+
missingDimensionCredits: confirmedRows.missingDimensionCredits
|
|
1264
|
+
},
|
|
1265
|
+
providerComparison: input.providerRead ? { availability: "available", provider: input.providerRead.provider, conversionCount: input.providerRead.conversionCount, sourceRef: input.providerRead.sourceRef } : { availability: "unavailable" }
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
function compareAnalyticsAttributionModels(input) {
|
|
1269
|
+
if (input.configurations.length < 1 || input.configurations.length > 3) throw new AnalyticsAttributionReportError("invalid_comparison_count", "Compare between one and three attribution configurations.");
|
|
1270
|
+
const ids = input.configurations.map((config, index) => config.id ?? `${config.model}:${config.clickWindow ?? 90}:${config.viewWindow ?? 90}:${index}`);
|
|
1271
|
+
if (new Set(ids).size !== ids.length) throw new AnalyticsAttributionReportError("duplicate_comparison_id", "Comparison configuration IDs must be unique.");
|
|
1272
|
+
return {
|
|
1273
|
+
evidence: {
|
|
1274
|
+
touchIds: input.touches.map((item) => item.touchId).sort(),
|
|
1275
|
+
conversionIds: input.conversions.map((item) => item.conversionId).sort(),
|
|
1276
|
+
spendFactIds: (input.spendFacts ?? []).map((item) => item.spendFactId).sort()
|
|
1277
|
+
},
|
|
1278
|
+
projections: input.configurations.map((configuration, index) => buildAnalyticsAttributionReport({
|
|
1279
|
+
...input,
|
|
1280
|
+
configuration: { ...configuration, id: ids[index] }
|
|
1281
|
+
}))
|
|
1282
|
+
};
|
|
1283
|
+
}
|
|
1284
|
+
async function readImmutableConfirmedAttributionEvidence(input) {
|
|
1285
|
+
const from = new Date(instant(input.from, "from")).toISOString();
|
|
1286
|
+
const to = new Date(instant(input.to, "to")).toISOString();
|
|
1287
|
+
const conversionsResult = await input.client.query(
|
|
1288
|
+
`SELECT id, person_id, occurred_at, conversion_kind, value_minor::text, currency
|
|
1289
|
+
FROM analytics_conversions
|
|
1290
|
+
WHERE site_id = $1 AND person_id IS NOT NULL AND occurred_at >= $2 AND occurred_at < $3
|
|
1291
|
+
ORDER BY occurred_at, id`,
|
|
1292
|
+
[input.siteId, from, to]
|
|
1293
|
+
);
|
|
1294
|
+
const touchesResult = await input.client.query(
|
|
1295
|
+
`SELECT id, person_id, occurred_at, touch_kind, source, medium, platform, account_id, campaign_id, campaign, ad_set_id, ad_id
|
|
1296
|
+
FROM analytics_attribution_touches
|
|
1297
|
+
WHERE site_id = $1 AND person_id IS NOT NULL AND occurred_at < $2
|
|
1298
|
+
ORDER BY occurred_at, id`,
|
|
1299
|
+
[input.siteId, to]
|
|
1300
|
+
);
|
|
1301
|
+
const spendResult = await input.client.query(
|
|
1302
|
+
`SELECT id, bucket, platform, account_id, campaign_id, campaign_name, ad_set_id, ad_set_name, ad_id, ad_name, spend_minor::text, currency, source_ref
|
|
1303
|
+
FROM analytics_ad_spend_facts
|
|
1304
|
+
WHERE site_id = $1 AND bucket >= $2::date AND bucket < $3::date
|
|
1305
|
+
ORDER BY bucket, id`,
|
|
1306
|
+
[input.siteId, from, to]
|
|
1307
|
+
);
|
|
1308
|
+
return {
|
|
1309
|
+
conversions: conversionsResult.rows.map((row) => ({
|
|
1310
|
+
conversionId: row.id,
|
|
1311
|
+
subjectRef: row.person_id,
|
|
1312
|
+
journeyTier: "confirmed",
|
|
1313
|
+
occurredAt: row.occurred_at,
|
|
1314
|
+
outcome: ["lead", "qualified", "conversion", "customer"].includes(row.conversion_kind) ? row.conversion_kind : "conversion",
|
|
1315
|
+
valueMinor: row.value_minor,
|
|
1316
|
+
currency: row.currency
|
|
1317
|
+
})),
|
|
1318
|
+
touches: touchesResult.rows.map((row) => ({
|
|
1319
|
+
touchId: row.id,
|
|
1320
|
+
subjectRef: row.person_id,
|
|
1321
|
+
journeyTier: "confirmed",
|
|
1322
|
+
occurredAt: row.occurred_at,
|
|
1323
|
+
touchKind: row.touch_kind,
|
|
1324
|
+
source: row.source,
|
|
1325
|
+
medium: row.medium,
|
|
1326
|
+
platform: row.platform,
|
|
1327
|
+
accountId: row.account_id,
|
|
1328
|
+
campaignId: row.campaign_id,
|
|
1329
|
+
campaignName: row.campaign,
|
|
1330
|
+
adSetId: row.ad_set_id,
|
|
1331
|
+
adId: row.ad_id
|
|
1332
|
+
})),
|
|
1333
|
+
spendFacts: spendResult.rows.map((row) => ({
|
|
1334
|
+
spendFactId: row.id,
|
|
1335
|
+
bucket: row.bucket,
|
|
1336
|
+
platform: row.platform,
|
|
1337
|
+
accountId: row.account_id,
|
|
1338
|
+
campaignId: row.campaign_id,
|
|
1339
|
+
campaignName: row.campaign_name,
|
|
1340
|
+
adSetId: row.ad_set_id,
|
|
1341
|
+
adSetName: row.ad_set_name,
|
|
1342
|
+
adId: row.ad_id,
|
|
1343
|
+
adName: row.ad_name,
|
|
1344
|
+
spendMinor: row.spend_minor,
|
|
1345
|
+
currency: row.currency,
|
|
1346
|
+
sourceRef: row.source_ref
|
|
1347
|
+
}))
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
async function readImmutableCandidateAttributionEvidence(input) {
|
|
1351
|
+
const from = new Date(instant(input.from, "from")).toISOString();
|
|
1352
|
+
const to = new Date(instant(input.to, "to")).toISOString();
|
|
1353
|
+
const touchesResult = await input.client.query(
|
|
1354
|
+
`SELECT t.id, cat.association_id, cat.association_confidence, t.occurred_at, t.touch_kind,
|
|
1355
|
+
t.source, t.medium, t.platform, t.account_id, t.campaign_id, t.campaign, t.ad_set_id, t.ad_id
|
|
1356
|
+
FROM analytics_candidate_attribution_touches cat
|
|
1357
|
+
JOIN analytics_candidate_associations ca ON ca.id=cat.association_id AND ca.namespace_id=cat.namespace_id
|
|
1358
|
+
JOIN analytics_attribution_touches t ON t.id=cat.touch_id AND t.site_id=cat.site_id
|
|
1359
|
+
WHERE cat.site_id=$1 AND cat.eligibility='overlay_only' AND cat.expires_at > now()
|
|
1360
|
+
AND ca.status='active' AND ca.confidence_band='high' AND ca.expires_at > now()
|
|
1361
|
+
AND t.occurred_at < $2
|
|
1362
|
+
ORDER BY t.occurred_at,t.id`,
|
|
1363
|
+
[input.siteId, to]
|
|
1364
|
+
);
|
|
1365
|
+
const conversionsResult = await input.client.query(
|
|
1366
|
+
`SELECT DISTINCT ON (c.id) c.id, cat.association_id, cat.association_confidence, c.occurred_at,
|
|
1367
|
+
c.conversion_kind, c.value_minor::text, c.currency
|
|
1368
|
+
FROM analytics_candidate_attribution_touches cat
|
|
1369
|
+
JOIN analytics_candidate_associations ca ON ca.id=cat.association_id AND ca.namespace_id=cat.namespace_id
|
|
1370
|
+
JOIN analytics_attribution_touches t ON t.id=cat.touch_id AND t.site_id=cat.site_id
|
|
1371
|
+
JOIN analytics_sessions s ON s.id=t.session_id AND s.site_id=t.site_id
|
|
1372
|
+
JOIN analytics_conversions c ON c.site_id=cat.site_id AND c.session_hmac=s.session_hmac
|
|
1373
|
+
WHERE cat.site_id=$1 AND cat.eligibility='overlay_only' AND cat.expires_at > now()
|
|
1374
|
+
AND ca.status='active' AND ca.confidence_band='high' AND ca.expires_at > now()
|
|
1375
|
+
AND c.occurred_at >= $2 AND c.occurred_at < $3
|
|
1376
|
+
ORDER BY c.id,cat.association_confidence DESC,cat.association_id`,
|
|
1377
|
+
[input.siteId, from, to]
|
|
1378
|
+
);
|
|
1379
|
+
return {
|
|
1380
|
+
touches: touchesResult.rows.map((row) => ({
|
|
1381
|
+
touchId: `candidate:${row.association_id}:${row.id}`,
|
|
1382
|
+
subjectRef: `candidate:${row.association_id}`,
|
|
1383
|
+
journeyTier: "candidate",
|
|
1384
|
+
candidateConfidence: "high",
|
|
1385
|
+
occurredAt: row.occurred_at,
|
|
1386
|
+
touchKind: row.touch_kind,
|
|
1387
|
+
source: row.source,
|
|
1388
|
+
medium: row.medium,
|
|
1389
|
+
platform: row.platform,
|
|
1390
|
+
accountId: row.account_id,
|
|
1391
|
+
campaignId: row.campaign_id,
|
|
1392
|
+
campaignName: row.campaign,
|
|
1393
|
+
adSetId: row.ad_set_id,
|
|
1394
|
+
adId: row.ad_id
|
|
1395
|
+
})),
|
|
1396
|
+
conversions: conversionsResult.rows.map((row) => ({
|
|
1397
|
+
conversionId: `candidate:${row.association_id}:${row.id}`,
|
|
1398
|
+
subjectRef: `candidate:${row.association_id}`,
|
|
1399
|
+
journeyTier: "candidate",
|
|
1400
|
+
candidateConfidence: "high",
|
|
1401
|
+
occurredAt: row.occurred_at,
|
|
1402
|
+
outcome: ["lead", "qualified", "conversion", "customer"].includes(row.conversion_kind) ? row.conversion_kind : "conversion",
|
|
1403
|
+
valueMinor: row.value_minor,
|
|
1404
|
+
currency: row.currency
|
|
1405
|
+
})),
|
|
1406
|
+
spendFacts: []
|
|
824
1407
|
};
|
|
825
1408
|
}
|
|
826
1409
|
|
|
@@ -1280,6 +1863,7 @@ async function migrateAnalytics() {
|
|
|
1280
1863
|
connection_ref text,
|
|
1281
1864
|
external_dataset_id text,
|
|
1282
1865
|
status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','paused','archived')),
|
|
1866
|
+
automatic_delivery_enabled boolean NOT NULL DEFAULT false,
|
|
1283
1867
|
event_mapping jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
1284
1868
|
created_by_user_id bigint NOT NULL,
|
|
1285
1869
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
@@ -1598,6 +2182,9 @@ async function migrateAnalytics() {
|
|
|
1598
2182
|
await client.query(
|
|
1599
2183
|
`ALTER TABLE analytics_activation_destinations ADD COLUMN IF NOT EXISTS mapping_version integer NOT NULL DEFAULT 1`
|
|
1600
2184
|
);
|
|
2185
|
+
await client.query(
|
|
2186
|
+
`ALTER TABLE analytics_activation_destinations ADD COLUMN IF NOT EXISTS automatic_delivery_enabled boolean NOT NULL DEFAULT false`
|
|
2187
|
+
);
|
|
1601
2188
|
await client.query(
|
|
1602
2189
|
`ALTER TABLE analytics_activation_jobs ADD COLUMN IF NOT EXISTS lease_until timestamptz`
|
|
1603
2190
|
);
|
|
@@ -1780,6 +2367,27 @@ async function migrateAnalytics() {
|
|
|
1780
2367
|
UNIQUE(site_id, user_id, milestone)
|
|
1781
2368
|
)
|
|
1782
2369
|
`);
|
|
2370
|
+
await client.query(`
|
|
2371
|
+
CREATE TABLE IF NOT EXISTS analytics_scheduled_occurrences (
|
|
2372
|
+
id uuid PRIMARY KEY,
|
|
2373
|
+
lane_key text NOT NULL,
|
|
2374
|
+
scheduled_for timestamptz NOT NULL,
|
|
2375
|
+
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','running','retrying','succeeded','dead_letter')),
|
|
2376
|
+
attempt_count integer NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
|
|
2377
|
+
max_attempts integer NOT NULL DEFAULT 5 CHECK (max_attempts BETWEEN 1 AND 20),
|
|
2378
|
+
next_attempt_at timestamptz NOT NULL,
|
|
2379
|
+
lease_owner text,
|
|
2380
|
+
lease_until timestamptz,
|
|
2381
|
+
started_at timestamptz,
|
|
2382
|
+
completed_at timestamptz,
|
|
2383
|
+
dead_lettered_at timestamptz,
|
|
2384
|
+
last_error_code text,
|
|
2385
|
+
result_summary jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
2386
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
2387
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
2388
|
+
UNIQUE(lane_key, scheduled_for)
|
|
2389
|
+
)
|
|
2390
|
+
`);
|
|
1783
2391
|
await client.query(`
|
|
1784
2392
|
CREATE TABLE IF NOT EXISTS analytics_purpose_policies (
|
|
1785
2393
|
id uuid PRIMARY KEY,
|
|
@@ -2025,6 +2633,27 @@ async function migrateAnalytics() {
|
|
|
2025
2633
|
UNIQUE(association_id, person_id)
|
|
2026
2634
|
)
|
|
2027
2635
|
`);
|
|
2636
|
+
await client.query(`
|
|
2637
|
+
CREATE TABLE IF NOT EXISTS analytics_identity_promotion_receipts (
|
|
2638
|
+
id uuid PRIMARY KEY,
|
|
2639
|
+
site_id uuid NOT NULL REFERENCES analytics_sites(id) ON DELETE CASCADE,
|
|
2640
|
+
namespace_id uuid NOT NULL,
|
|
2641
|
+
canonical_person_id uuid NOT NULL,
|
|
2642
|
+
canonical_person_ref_hmac text NOT NULL,
|
|
2643
|
+
evidence_kind text NOT NULL,
|
|
2644
|
+
evidence_ref_hmac text NOT NULL,
|
|
2645
|
+
prior_person_ids uuid[] NOT NULL DEFAULT '{}',
|
|
2646
|
+
prior_identity_node_ids uuid[] NOT NULL DEFAULT '{}',
|
|
2647
|
+
candidate_association_ids uuid[] NOT NULL DEFAULT '{}',
|
|
2648
|
+
affected_history_counts jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(affected_history_counts)='object'),
|
|
2649
|
+
schema_version text NOT NULL DEFAULT 'xray.identity-promotion.v1',
|
|
2650
|
+
idempotency_key text NOT NULL,
|
|
2651
|
+
promoted_at timestamptz NOT NULL,
|
|
2652
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
2653
|
+
FOREIGN KEY(namespace_id, site_id) REFERENCES analytics_identity_namespaces(id, site_id) ON DELETE CASCADE,
|
|
2654
|
+
UNIQUE(site_id, idempotency_key)
|
|
2655
|
+
)
|
|
2656
|
+
`);
|
|
2028
2657
|
await client.query(
|
|
2029
2658
|
"CREATE UNIQUE INDEX IF NOT EXISTS analytics_touches_site_ref ON analytics_attribution_touches(id, site_id)"
|
|
2030
2659
|
);
|
|
@@ -2261,6 +2890,7 @@ async function migrateAnalytics() {
|
|
|
2261
2890
|
last_started_at timestamptz,
|
|
2262
2891
|
last_success_at timestamptz,
|
|
2263
2892
|
next_run_at timestamptz,
|
|
2893
|
+
interval_seconds integer CHECK (interval_seconds IS NULL OR (interval_seconds >= 900 AND interval_seconds <= 604800)),
|
|
2264
2894
|
imported integer NOT NULL DEFAULT 0 CHECK (imported >= 0),
|
|
2265
2895
|
deduplicated integer NOT NULL DEFAULT 0 CHECK (deduplicated >= 0),
|
|
2266
2896
|
rejected integer NOT NULL DEFAULT 0 CHECK (rejected >= 0),
|
|
@@ -2270,6 +2900,7 @@ async function migrateAnalytics() {
|
|
|
2270
2900
|
UNIQUE(site_id, idempotency_key)
|
|
2271
2901
|
)
|
|
2272
2902
|
`);
|
|
2903
|
+
await client.query("ALTER TABLE analytics_crm_sync_runs ADD COLUMN IF NOT EXISTS interval_seconds integer CHECK (interval_seconds IS NULL OR (interval_seconds >= 900 AND interval_seconds <= 604800))");
|
|
2273
2904
|
await client.query("CREATE INDEX IF NOT EXISTS analytics_crm_sync_runs_due ON analytics_crm_sync_runs(state, next_run_at) WHERE next_run_at IS NOT NULL");
|
|
2274
2905
|
await client.query(`
|
|
2275
2906
|
CREATE TABLE IF NOT EXISTS analytics_crm_outbound_policies (
|
|
@@ -2560,6 +3191,12 @@ async function migrateAnalytics() {
|
|
|
2560
3191
|
await client.query(
|
|
2561
3192
|
"CREATE INDEX IF NOT EXISTS analytics_product_milestones_site_time ON analytics_product_milestones(site_id, occurred_at DESC)"
|
|
2562
3193
|
);
|
|
3194
|
+
await client.query(
|
|
3195
|
+
"CREATE INDEX IF NOT EXISTS analytics_scheduled_occurrences_due ON analytics_scheduled_occurrences(status,next_attempt_at,scheduled_for) WHERE status IN ('pending','retrying')"
|
|
3196
|
+
);
|
|
3197
|
+
await client.query(
|
|
3198
|
+
"CREATE INDEX IF NOT EXISTS analytics_scheduled_occurrences_lease ON analytics_scheduled_occurrences(lease_until) WHERE status='running'"
|
|
3199
|
+
);
|
|
2563
3200
|
await client.query(
|
|
2564
3201
|
"CREATE INDEX IF NOT EXISTS analytics_consent_receipts_subject_time ON analytics_consent_receipts(site_id, subject_ref, occurred_at DESC)"
|
|
2565
3202
|
);
|
|
@@ -2584,6 +3221,9 @@ async function migrateAnalytics() {
|
|
|
2584
3221
|
await client.query(
|
|
2585
3222
|
"CREATE INDEX IF NOT EXISTS analytics_candidate_promotions_person_time ON analytics_candidate_promotions(site_id, person_id, promoted_at DESC)"
|
|
2586
3223
|
);
|
|
3224
|
+
await client.query(
|
|
3225
|
+
"CREATE INDEX IF NOT EXISTS analytics_identity_promotion_receipts_person_time ON analytics_identity_promotion_receipts(site_id, canonical_person_id, promoted_at DESC)"
|
|
3226
|
+
);
|
|
2587
3227
|
await client.query(
|
|
2588
3228
|
"CREATE INDEX IF NOT EXISTS analytics_candidate_touches_overlay ON analytics_candidate_attribution_touches(site_id, namespace_id, linked_at DESC) WHERE eligibility = 'overlay_only'"
|
|
2589
3229
|
);
|
|
@@ -2625,7 +3265,7 @@ async function migrateAnalytics() {
|
|
|
2625
3265
|
);
|
|
2626
3266
|
await client.query(`
|
|
2627
3267
|
INSERT INTO analytics_schema_migrations(version)
|
|
2628
|
-
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'), ('2026-08-28.1')
|
|
3268
|
+
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'), ('2026-08-28.1'), ('2026-08-28.2'), ('2026-08-28.3'), ('2026-08-28.4')
|
|
2629
3269
|
ON CONFLICT (version) DO NOTHING
|
|
2630
3270
|
`);
|
|
2631
3271
|
} catch (error) {
|
|
@@ -3629,6 +4269,20 @@ async function ingestAnalyticsEvents(input) {
|
|
|
3629
4269
|
});
|
|
3630
4270
|
const attribution = resolveAnalyticsAttribution(event);
|
|
3631
4271
|
const eventIdentity = event.identity;
|
|
4272
|
+
let eventDefinitionId = null;
|
|
4273
|
+
let eventDefinitionVersion = null;
|
|
4274
|
+
if (event.eventDefinitionId && event.eventDefinitionVersion) {
|
|
4275
|
+
const definitionMatch = await client.query(
|
|
4276
|
+
`SELECT id, version FROM analytics_event_definitions
|
|
4277
|
+
WHERE id=$1 AND site_id=$2 AND version=$3 AND event_name=$4 AND status='active'
|
|
4278
|
+
LIMIT 1`,
|
|
4279
|
+
[event.eventDefinitionId, pixel.site_id, event.eventDefinitionVersion, event.eventName]
|
|
4280
|
+
);
|
|
4281
|
+
if (definitionMatch.rows[0]) {
|
|
4282
|
+
eventDefinitionId = definitionMatch.rows[0].id;
|
|
4283
|
+
eventDefinitionVersion = definitionMatch.rows[0].version;
|
|
4284
|
+
}
|
|
4285
|
+
}
|
|
3632
4286
|
const resolvedPerson = analyticsIdentityResolutionAllowed(event) && (event.visitorId || event.sessionId) ? await client.query(
|
|
3633
4287
|
`SELECT e.person_id FROM analytics_identity_nodes n JOIN analytics_identity_edges e ON e.identity_node_id = n.id
|
|
3634
4288
|
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))
|
|
@@ -3645,10 +4299,10 @@ async function ingestAnalyticsEvents(input) {
|
|
|
3645
4299
|
occurred_at, path, canonical_url, title, referrer, source, medium, campaign,
|
|
3646
4300
|
device_class, properties, country_code, region_code, click_ids, person_id,
|
|
3647
4301
|
channel_family, platform, campaign_id, ad_set_id, ad_id, creative_id, placement, utm_term, utm_content,
|
|
3648
|
-
engaged_ms, scroll_depth, visitor_hmac, session_hmac
|
|
4302
|
+
engaged_ms, scroll_depth, visitor_hmac, session_hmac, event_definition_id, event_definition_version
|
|
3649
4303
|
) VALUES (
|
|
3650
4304
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18::jsonb, $19, $20, $21::jsonb, $22,
|
|
3651
|
-
$23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35
|
|
4305
|
+
$23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37
|
|
3652
4306
|
) ON CONFLICT(site_id, event_id) DO NOTHING`,
|
|
3653
4307
|
[
|
|
3654
4308
|
randomUUID2(),
|
|
@@ -3685,7 +4339,9 @@ async function ingestAnalyticsEvents(input) {
|
|
|
3685
4339
|
boundedEngagedMs(event.engagedMs),
|
|
3686
4340
|
boundedScrollDepth(event.scrollDepth),
|
|
3687
4341
|
event.visitorId ? identityHmac("visitor_id", event.visitorId) : null,
|
|
3688
|
-
event.sessionId ? identityHmac("session_id", event.sessionId) : null
|
|
4342
|
+
event.sessionId ? identityHmac("session_id", event.sessionId) : null,
|
|
4343
|
+
eventDefinitionId,
|
|
4344
|
+
eventDefinitionVersion
|
|
3689
4345
|
]
|
|
3690
4346
|
);
|
|
3691
4347
|
if (inserted.rowCount) {
|
|
@@ -3715,7 +4371,8 @@ async function ingestAnalyticsEvents(input) {
|
|
|
3715
4371
|
phone: eventIdentity.phone,
|
|
3716
4372
|
customerId: eventIdentity.customerId,
|
|
3717
4373
|
orderId: eventIdentity.orderId,
|
|
3718
|
-
clickIds: event.clickIds
|
|
4374
|
+
clickIds: event.clickIds,
|
|
4375
|
+
occurredAt: event.occurredAt
|
|
3719
4376
|
});
|
|
3720
4377
|
}
|
|
3721
4378
|
} else {
|
|
@@ -3861,11 +4518,12 @@ async function createAnalyticsConversion(input) {
|
|
|
3861
4518
|
let queuedDestinations = 0;
|
|
3862
4519
|
if (created && input.activationPayloadCiphertext) {
|
|
3863
4520
|
const destinations = await client.query(
|
|
3864
|
-
`SELECT id FROM analytics_activation_destinations
|
|
3865
|
-
WHERE site_id = $1 AND status = 'active' AND
|
|
4521
|
+
`SELECT id,event_mapping FROM analytics_activation_destinations
|
|
4522
|
+
WHERE site_id = $1 AND status = 'active' AND automatic_delivery_enabled=true
|
|
4523
|
+
AND readiness IN ('verified','live')`,
|
|
3866
4524
|
[input.siteId]
|
|
3867
4525
|
);
|
|
3868
|
-
for (const destination of destinations.rows) {
|
|
4526
|
+
for (const destination of destinations.rows.filter((row) => activationMappingAllows(row.event_mapping, input.conversionKind))) {
|
|
3869
4527
|
const queued = await client.query(
|
|
3870
4528
|
`INSERT INTO analytics_activation_jobs(
|
|
3871
4529
|
id, destination_id, conversion_id, person_id, payload_ciphertext
|
|
@@ -4095,127 +4753,103 @@ async function analyticsAcquisition(siteId, userId, filters, page) {
|
|
|
4095
4753
|
const db = getAnalyticsPool();
|
|
4096
4754
|
await accessFor(db, siteId, userId);
|
|
4097
4755
|
const query = eventFilterSql(siteId, filters);
|
|
4756
|
+
const eventWhere = filters.journeyTier === "best_guess" ? `${query.where} AND EXISTS (
|
|
4757
|
+
SELECT 1 FROM analytics_sessions candidate_session
|
|
4758
|
+
JOIN analytics_attribution_touches candidate_touch
|
|
4759
|
+
ON candidate_touch.session_id=candidate_session.id AND candidate_touch.site_id=candidate_session.site_id
|
|
4760
|
+
JOIN analytics_candidate_attribution_touches candidate_overlay
|
|
4761
|
+
ON candidate_overlay.touch_id=candidate_touch.id AND candidate_overlay.site_id=candidate_touch.site_id
|
|
4762
|
+
JOIN analytics_candidate_associations candidate_association
|
|
4763
|
+
ON candidate_association.id=candidate_overlay.association_id
|
|
4764
|
+
AND candidate_association.namespace_id=candidate_overlay.namespace_id
|
|
4765
|
+
WHERE candidate_session.site_id=e.site_id AND candidate_session.session_hmac=e.session_hmac
|
|
4766
|
+
AND candidate_overlay.eligibility='overlay_only' AND candidate_overlay.expires_at > now()
|
|
4767
|
+
AND candidate_association.status='active' AND candidate_association.confidence_band='high'
|
|
4768
|
+
AND candidate_association.expires_at > now()
|
|
4769
|
+
)` : `${query.where} AND e.person_id IS NOT NULL`;
|
|
4098
4770
|
const result = await db.query(
|
|
4099
4771
|
`SELECT COALESCE(e.source, '(direct)') AS source,
|
|
4100
4772
|
COALESCE(e.medium, '(none)') AS medium,
|
|
4101
4773
|
COALESCE(e.campaign, '(none)') AS campaign,
|
|
4774
|
+
COALESCE(e.platform, e.source, '(direct)') AS platform,
|
|
4775
|
+
e.campaign_id, e.ad_set_id, e.ad_id, e.creative_id,
|
|
4102
4776
|
count(DISTINCT e.visitor_id)::int AS visitors,
|
|
4103
4777
|
count(DISTINCT e.session_id)::int AS sessions,
|
|
4104
4778
|
count(*) FILTER (WHERE e.event_name = 'page_view')::int AS pageviews
|
|
4105
4779
|
FROM analytics_events e
|
|
4106
|
-
WHERE ${
|
|
4107
|
-
GROUP BY 1, 2, 3
|
|
4780
|
+
WHERE ${eventWhere}
|
|
4781
|
+
GROUP BY 1, 2, 3, 4, 5, 6, 7, 8
|
|
4108
4782
|
ORDER BY sessions DESC, visitors DESC
|
|
4109
4783
|
LIMIT 1000`,
|
|
4110
4784
|
query.values
|
|
4111
4785
|
);
|
|
4112
|
-
const
|
|
4113
|
-
|
|
4114
|
-
"
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4786
|
+
const [confirmedEvidence, candidateEvidence] = await Promise.all([
|
|
4787
|
+
readImmutableConfirmedAttributionEvidence({ client: db, siteId, from: filters.start, to: filters.end }),
|
|
4788
|
+
filters.journeyTier === "best_guess" || filters.journeyTier === "all" ? readImmutableCandidateAttributionEvidence({ client: db, siteId, from: filters.start, to: filters.end }) : Promise.resolve({ touches: [], conversions: [], spendFacts: [] })
|
|
4789
|
+
]);
|
|
4790
|
+
const touchMatches = (touch) => (!filters.source || (touch.source ?? "(direct)") === filters.source) && (!filters.medium || (touch.medium ?? "(none)") === filters.medium) && (!filters.campaign || (touch.campaignName ?? "(none)") === filters.campaign) && (!filters.platform || (touch.platform ?? touch.source ?? "(direct)") === filters.platform);
|
|
4791
|
+
confirmedEvidence.touches = confirmedEvidence.touches.filter(touchMatches);
|
|
4792
|
+
candidateEvidence.touches = candidateEvidence.touches.filter(touchMatches);
|
|
4793
|
+
if (filters.conversionKind) {
|
|
4794
|
+
confirmedEvidence.conversions = confirmedEvidence.conversions.filter((item) => item.outcome === filters.conversionKind);
|
|
4795
|
+
candidateEvidence.conversions = candidateEvidence.conversions.filter((item) => item.outcome === filters.conversionKind);
|
|
4796
|
+
}
|
|
4797
|
+
const configuration = {
|
|
4798
|
+
model: filters.attributionModel ?? "position_based",
|
|
4799
|
+
clickWindow: filters.clickWindow ?? 90,
|
|
4800
|
+
viewWindow: filters.viewWindow ?? 30,
|
|
4801
|
+
customWeights: filters.customWeights
|
|
4121
4802
|
};
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
);
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
);
|
|
4142
|
-
if (filters.referrer)
|
|
4143
|
-
addTouch(
|
|
4144
|
-
`COALESCE(touch.referrer, '') ILIKE '%' || ? || '%'`,
|
|
4145
|
-
filters.referrer
|
|
4146
|
-
);
|
|
4147
|
-
if (filters.path) addTouch(`touch.path = ?`, filters.path);
|
|
4148
|
-
if (filters.deviceClass)
|
|
4149
|
-
addTouch(
|
|
4150
|
-
`COALESCE(touch.device_class, 'unknown') = ?`,
|
|
4151
|
-
filters.deviceClass
|
|
4152
|
-
);
|
|
4153
|
-
if (filters.countryCode)
|
|
4154
|
-
addTouch(
|
|
4155
|
-
`COALESCE(touch.country_code, 'unknown') = ?`,
|
|
4156
|
-
filters.countryCode.toUpperCase()
|
|
4157
|
-
);
|
|
4158
|
-
if (filters.regionCode)
|
|
4159
|
-
addTouch(
|
|
4160
|
-
`COALESCE(touch.region_code, 'unknown') = ?`,
|
|
4161
|
-
filters.regionCode.toUpperCase()
|
|
4162
|
-
);
|
|
4163
|
-
if (filters.conversionKind)
|
|
4164
|
-
addTouch(`c.conversion_kind = ?`, filters.conversionKind);
|
|
4165
|
-
const direction = filters.attributionModel === "last_touch" ? "DESC" : "ASC";
|
|
4166
|
-
const attributed = await db.query(
|
|
4167
|
-
`SELECT COALESCE(touch.source, '(direct)') AS source,
|
|
4168
|
-
COALESCE(touch.medium, '(none)') AS medium,
|
|
4169
|
-
COALESCE(touch.campaign, '(none)') AS campaign,
|
|
4170
|
-
count(*)::int AS conversions, COALESCE(sum(c.value_minor), 0)::bigint AS revenue_minor
|
|
4171
|
-
FROM analytics_conversions c
|
|
4172
|
-
JOIN LATERAL (
|
|
4173
|
-
SELECT e.* FROM analytics_events e
|
|
4174
|
-
WHERE e.site_id = c.site_id AND e.occurred_at <= c.occurred_at
|
|
4175
|
-
AND ((c.person_id IS NOT NULL AND e.person_id = c.person_id)
|
|
4176
|
-
OR (c.person_id IS NULL AND e.session_id = c.session_id))
|
|
4177
|
-
ORDER BY e.occurred_at ${direction} LIMIT 1
|
|
4178
|
-
) touch ON true
|
|
4179
|
-
WHERE ${touchClauses.join(" AND ")}
|
|
4180
|
-
GROUP BY 1,2,3`,
|
|
4181
|
-
values
|
|
4182
|
-
);
|
|
4183
|
-
const outcomes = new Map(
|
|
4184
|
-
attributed.rows.map((row) => [
|
|
4185
|
-
`${row.source}\0${row.medium}\0${row.campaign}`,
|
|
4186
|
-
row
|
|
4187
|
-
])
|
|
4188
|
-
);
|
|
4189
|
-
const rows = result.rows.map((row) => {
|
|
4190
|
-
const outcome = outcomes.get(
|
|
4191
|
-
`${row.source}\0${row.medium}\0${row.campaign}`
|
|
4192
|
-
);
|
|
4193
|
-
const conversions = Number(outcome?.conversions ?? 0);
|
|
4803
|
+
const evidence = {
|
|
4804
|
+
touches: [...confirmedEvidence.touches, ...candidateEvidence.touches],
|
|
4805
|
+
conversions: [...confirmedEvidence.conversions, ...candidateEvidence.conversions],
|
|
4806
|
+
spendFacts: confirmedEvidence.spendFacts
|
|
4807
|
+
};
|
|
4808
|
+
const projection = buildAnalyticsAttributionReport({ ...evidence, configuration, granularity: "campaign" });
|
|
4809
|
+
const selectedTier = filters.journeyTier === "best_guess" ? "candidate" : "confirmed";
|
|
4810
|
+
const selectedCredits = selectedTier === "candidate" ? projection.candidateAssist.credits : projection.confirmed.credits;
|
|
4811
|
+
const selectedRows = selectedTier === "candidate" ? projection.candidateAssist.rows : projection.confirmed.rows;
|
|
4812
|
+
const outcomes = summarizeAttributionCredits(selectedCredits, evidence.touches);
|
|
4813
|
+
const traffic = new Map(result.rows.map((row) => [`${row.source}\0${row.medium}\0${row.campaign}`, row]));
|
|
4814
|
+
const efficiency = new Map(selectedRows.map((row) => [`${row.platform ?? ""}\0${row.campaignId ?? ""}`, row]));
|
|
4815
|
+
const keys = /* @__PURE__ */ new Set([...traffic.keys(), ...outcomes.keys()]);
|
|
4816
|
+
const rows = [...keys].map((key) => {
|
|
4817
|
+
const visit = traffic.get(key);
|
|
4818
|
+
const outcome = outcomes.get(key);
|
|
4819
|
+
const platform = outcome?.platform ?? visit?.platform ?? null;
|
|
4820
|
+
const campaignId = outcome?.campaignId ?? visit?.campaign_id ?? null;
|
|
4821
|
+
const metrics = efficiency.get(`${platform ?? ""}\0${campaignId ?? ""}`);
|
|
4822
|
+
const sessions = Number(visit?.sessions ?? 0);
|
|
4194
4823
|
return {
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4824
|
+
source: outcome?.source ?? visit?.source ?? "(direct)",
|
|
4825
|
+
medium: outcome?.medium ?? visit?.medium ?? "(none)",
|
|
4826
|
+
campaign: outcome?.campaign ?? visit?.campaign ?? "(none)",
|
|
4827
|
+
platform,
|
|
4828
|
+
campaignId,
|
|
4829
|
+
adSetGroupId: visit?.ad_set_id ?? null,
|
|
4830
|
+
adId: visit?.ad_id ?? null,
|
|
4831
|
+
creativeId: visit?.creative_id ?? null,
|
|
4832
|
+
visitors: Number(visit?.visitors ?? 0),
|
|
4833
|
+
sessions,
|
|
4834
|
+
pageviews: Number(visit?.pageviews ?? 0),
|
|
4835
|
+
leads: outcome?.leads ?? 0,
|
|
4836
|
+
qualifiedLeads: outcome?.qualifiedLeads ?? 0,
|
|
4837
|
+
conversions: outcome?.conversions ?? 0,
|
|
4838
|
+
customers: outcome?.customers ?? 0,
|
|
4839
|
+
conversionRate: sessions > 0 ? (outcome?.conversions ?? 0) / sessions : 0,
|
|
4840
|
+
revenueMinor: outcome?.revenueMinor ?? 0,
|
|
4841
|
+
spendMinor: metrics?.spendMinor == null ? null : Number(metrics.spendMinor),
|
|
4842
|
+
cplMinor: metrics?.cplMinor ?? null,
|
|
4843
|
+
cpqlMinor: metrics?.cpqlMinor ?? null,
|
|
4844
|
+
cpaMinor: metrics?.cpaMinor ?? null,
|
|
4845
|
+
cacMinor: metrics?.cacMinor ?? null,
|
|
4846
|
+
roas: metrics?.roas ?? null,
|
|
4847
|
+
scoreEligible: null,
|
|
4848
|
+
propensityEligible: null,
|
|
4849
|
+
coveragePercent: outcome && outcome.attributedCredit > 0 ? 1 : 0,
|
|
4850
|
+
currency: metrics?.currency ?? selectedCredits.find((credit) => credit.currency)?.currency ?? null
|
|
4199
4851
|
};
|
|
4200
4852
|
});
|
|
4201
|
-
for (const outcome of attributed.rows) {
|
|
4202
|
-
const key = `${outcome.source}\0${outcome.medium}\0${outcome.campaign}`;
|
|
4203
|
-
if (!result.rows.some(
|
|
4204
|
-
(row) => `${row.source}\0${row.medium}\0${row.campaign}` === key
|
|
4205
|
-
)) {
|
|
4206
|
-
rows.push({
|
|
4207
|
-
source: outcome.source,
|
|
4208
|
-
medium: outcome.medium,
|
|
4209
|
-
campaign: outcome.campaign,
|
|
4210
|
-
visitors: 0,
|
|
4211
|
-
sessions: 0,
|
|
4212
|
-
pageviews: 0,
|
|
4213
|
-
conversions: Number(outcome.conversions),
|
|
4214
|
-
conversionRate: 0,
|
|
4215
|
-
revenueMinor: Number(outcome.revenue_minor)
|
|
4216
|
-
});
|
|
4217
|
-
}
|
|
4218
|
-
}
|
|
4219
4853
|
rows.sort(
|
|
4220
4854
|
(a, b) => Number(b.sessions) - Number(a.sessions) || Number(b.conversions) - Number(a.conversions)
|
|
4221
4855
|
);
|
|
@@ -4226,10 +4860,72 @@ async function analyticsAcquisition(siteId, userId, filters, page) {
|
|
|
4226
4860
|
);
|
|
4227
4861
|
return {
|
|
4228
4862
|
channels: paged.items,
|
|
4863
|
+
candidateAssistChannels: filters.journeyTier === "all" ? [...summarizeAttributionCredits(projection.candidateAssist.credits, evidence.touches).values()] : void 0,
|
|
4864
|
+
comparisons: summarizeAttributionComparisons(
|
|
4865
|
+
filters.compareModels?.length ? compareAnalyticsAttributionModels({
|
|
4866
|
+
...evidence,
|
|
4867
|
+
configurations: [configuration, ...filters.compareModels.map((model) => ({ ...configuration, model, id: model }))],
|
|
4868
|
+
granularity: "campaign"
|
|
4869
|
+
}).projections : [projection],
|
|
4870
|
+
selectedTier
|
|
4871
|
+
),
|
|
4872
|
+
attribution: {
|
|
4873
|
+
model: projection.configuration.model,
|
|
4874
|
+
clickWindow: projection.configuration.clickWindow,
|
|
4875
|
+
viewWindow: projection.configuration.viewWindow,
|
|
4876
|
+
journeyTier: filters.journeyTier ?? "confirmed",
|
|
4877
|
+
confirmedAndCandidateTotalsSeparated: true
|
|
4878
|
+
},
|
|
4229
4879
|
pageInfo: paged.pageInfo,
|
|
4230
4880
|
dataFreshThrough: (/* @__PURE__ */ new Date()).toISOString()
|
|
4231
4881
|
};
|
|
4232
4882
|
}
|
|
4883
|
+
function summarizeAttributionCredits(credits, touches) {
|
|
4884
|
+
const touchById = new Map(touches.map((touch) => [touch.touchId, touch]));
|
|
4885
|
+
const rows = /* @__PURE__ */ new Map();
|
|
4886
|
+
for (const credit of credits) {
|
|
4887
|
+
const touch = credit.touchId ? touchById.get(credit.touchId) : void 0;
|
|
4888
|
+
const source = touch?.source ?? "(unattributed)";
|
|
4889
|
+
const medium = touch?.medium ?? "(none)";
|
|
4890
|
+
const campaign = touch?.campaignName ?? "(none)";
|
|
4891
|
+
const key = `${source}\0${medium}\0${campaign}`;
|
|
4892
|
+
const row = rows.get(key) ?? {
|
|
4893
|
+
source,
|
|
4894
|
+
medium,
|
|
4895
|
+
campaign,
|
|
4896
|
+
platform: touch?.platform ?? null,
|
|
4897
|
+
campaignId: touch?.campaignId ?? null,
|
|
4898
|
+
leads: 0,
|
|
4899
|
+
qualifiedLeads: 0,
|
|
4900
|
+
conversions: 0,
|
|
4901
|
+
customers: 0,
|
|
4902
|
+
revenueMinor: 0,
|
|
4903
|
+
attributedCredit: 0
|
|
4904
|
+
};
|
|
4905
|
+
const fraction = credit.creditMicros / 1e6;
|
|
4906
|
+
if (credit.outcome === "lead") row.leads += fraction;
|
|
4907
|
+
if (credit.outcome === "qualified") row.qualifiedLeads += fraction;
|
|
4908
|
+
if (credit.outcome === "conversion") row.conversions += fraction;
|
|
4909
|
+
if (credit.outcome === "customer") row.customers += fraction;
|
|
4910
|
+
row.attributedCredit += fraction;
|
|
4911
|
+
row.revenueMinor += Number(credit.creditedValueMinor);
|
|
4912
|
+
rows.set(key, row);
|
|
4913
|
+
}
|
|
4914
|
+
return rows;
|
|
4915
|
+
}
|
|
4916
|
+
function summarizeAttributionComparisons(projections, tier) {
|
|
4917
|
+
return projections.map((projection) => {
|
|
4918
|
+
const section = tier === "candidate" ? projection.candidateAssist : projection.confirmed;
|
|
4919
|
+
const conversionIds = new Set(section.credits.map((credit) => credit.conversionId));
|
|
4920
|
+
const attributedIds = new Set(section.credits.filter((credit) => !credit.unattributed).map((credit) => credit.conversionId));
|
|
4921
|
+
return {
|
|
4922
|
+
model: projection.configuration.model,
|
|
4923
|
+
conversions: section.credits.reduce((sum, credit) => sum + credit.creditMicros / 1e6, 0),
|
|
4924
|
+
revenueMinor: section.credits.reduce((sum, credit) => sum + Number(credit.creditedValueMinor), 0),
|
|
4925
|
+
coveragePercent: conversionIds.size > 0 ? attributedIds.size / conversionIds.size : null
|
|
4926
|
+
};
|
|
4927
|
+
});
|
|
4928
|
+
}
|
|
4233
4929
|
var inferredFamilySql = `COALESCE(e.channel_family, CASE
|
|
4234
4930
|
WHEN lower(COALESCE(e.source,'') || ' ' || COALESCE(e.referrer,'')) ~ '(perplexity|chatgpt|openai|claude|anthropic|grok|xai|ai[_ -]?overview|google[_ -]?ai|gemini)' THEN 'llm'
|
|
4235
4931
|
WHEN lower(COALESCE(e.source,'') || ' ' || COALESCE(e.referrer,'')) ~ '(facebook|instagram|linkedin|tiktok|youtube|youtu\\.be|twitter|x\\.com)' THEN 'social'
|
|
@@ -4977,7 +5673,7 @@ async function resolveAndMergeConfirmedAnalyticsPerson(client, input) {
|
|
|
4977
5673
|
const matches = await client.query(
|
|
4978
5674
|
`SELECT p.id
|
|
4979
5675
|
FROM analytics_people p
|
|
4980
|
-
JOIN (
|
|
5676
|
+
LEFT JOIN (
|
|
4981
5677
|
SELECT DISTINCT e.person_id
|
|
4982
5678
|
FROM analytics_identity_nodes n
|
|
4983
5679
|
JOIN analytics_identity_edges e ON e.identity_node_id=n.id AND e.site_id=n.site_id
|
|
@@ -4987,12 +5683,15 @@ async function resolveAndMergeConfirmedAnalyticsPerson(client, input) {
|
|
|
4987
5683
|
)
|
|
4988
5684
|
) matched ON matched.person_id=p.id
|
|
4989
5685
|
WHERE p.site_id=$1 AND (p.namespace_id=$3 OR p.namespace_id IS NULL)
|
|
4990
|
-
|
|
5686
|
+
AND (matched.person_id IS NOT NULL OR p.crm_person_ref=$4)
|
|
5687
|
+
ORDER BY CASE WHEN p.crm_person_ref LIKE 'XRay::anonymous::%' THEN 1 ELSE 0 END,
|
|
5688
|
+
p.first_seen_at ASC,p.id ASC
|
|
4991
5689
|
FOR UPDATE OF p`,
|
|
4992
5690
|
[
|
|
4993
5691
|
input.siteId,
|
|
4994
5692
|
JSON.stringify(input.signals.map((signal) => ({ kind: signal.kind, value_hmac: signal.valueHmac }))),
|
|
4995
|
-
input.namespaceId
|
|
5693
|
+
input.namespaceId,
|
|
5694
|
+
input.crmPersonRef
|
|
4996
5695
|
]
|
|
4997
5696
|
);
|
|
4998
5697
|
const canonicalId = matches.rows[0]?.id;
|
|
@@ -5004,16 +5703,17 @@ async function resolveAndMergeConfirmedAnalyticsPerson(client, input) {
|
|
|
5004
5703
|
public_ref=COALESCE(analytics_people.public_ref,EXCLUDED.public_ref) RETURNING id`,
|
|
5005
5704
|
[randomUUID2(), input.siteId, input.namespaceId, input.crmPersonRef, newAnalyticsPersonPublicRef()]
|
|
5006
5705
|
);
|
|
5007
|
-
return inserted.rows[0].id;
|
|
5706
|
+
return { personId: inserted.rows[0].id, mergedPersonIds: [] };
|
|
5008
5707
|
}
|
|
5009
5708
|
await client.query(
|
|
5010
5709
|
`UPDATE analytics_people SET last_seen_at=now(),
|
|
5011
|
-
namespace_id=COALESCE(namespace_id,$2),public_ref=COALESCE(public_ref,$3)
|
|
5710
|
+
namespace_id=COALESCE(namespace_id,$2),public_ref=COALESCE(public_ref,$3),
|
|
5711
|
+
crm_person_ref=CASE WHEN crm_person_ref LIKE 'XRay::anonymous::%' THEN $5 ELSE crm_person_ref END
|
|
5012
5712
|
WHERE id=$1 AND site_id=$4`,
|
|
5013
|
-
[canonicalId, input.namespaceId, newAnalyticsPersonPublicRef(), input.siteId]
|
|
5713
|
+
[canonicalId, input.namespaceId, newAnalyticsPersonPublicRef(), input.siteId, input.crmPersonRef]
|
|
5014
5714
|
);
|
|
5015
5715
|
const duplicateIds = matches.rows.slice(1).map((row) => row.id);
|
|
5016
|
-
if (duplicateIds.length === 0) return canonicalId;
|
|
5716
|
+
if (duplicateIds.length === 0) return { personId: canonicalId, mergedPersonIds: [] };
|
|
5017
5717
|
await client.query(
|
|
5018
5718
|
`DELETE FROM analytics_identity_edges duplicate
|
|
5019
5719
|
USING analytics_identity_edges canonical
|
|
@@ -5105,7 +5805,106 @@ async function resolveAndMergeConfirmedAnalyticsPerson(client, input) {
|
|
|
5105
5805
|
"DELETE FROM analytics_people WHERE site_id=$1 AND id=ANY($2::uuid[])",
|
|
5106
5806
|
[input.siteId, duplicateIds]
|
|
5107
5807
|
);
|
|
5108
|
-
return canonicalId;
|
|
5808
|
+
return { personId: canonicalId, mergedPersonIds: duplicateIds };
|
|
5809
|
+
}
|
|
5810
|
+
async function promoteAnalyticsCandidatesForConfirmedPerson(client, input) {
|
|
5811
|
+
const associations = await client.query(
|
|
5812
|
+
`WITH person_nodes AS (
|
|
5813
|
+
SELECT DISTINCT identity_node_id
|
|
5814
|
+
FROM analytics_identity_edges
|
|
5815
|
+
WHERE site_id=$1 AND namespace_id=$2 AND person_id=$3
|
|
5816
|
+
)
|
|
5817
|
+
UPDATE analytics_candidate_associations a
|
|
5818
|
+
SET status='promoted',promoted_person_id=$3,promoted_at=$4::timestamptz,updated_at=now()
|
|
5819
|
+
WHERE a.site_id=$1 AND a.namespace_id=$2
|
|
5820
|
+
AND (a.left_identity_node_id IN (SELECT identity_node_id FROM person_nodes)
|
|
5821
|
+
OR a.right_identity_node_id IN (SELECT identity_node_id FROM person_nodes))
|
|
5822
|
+
AND (a.status IN ('active','expired') OR (a.status='promoted' AND a.promoted_person_id=$3))
|
|
5823
|
+
RETURNING a.id`,
|
|
5824
|
+
[input.siteId, input.namespaceId, input.personId, input.occurredAt]
|
|
5825
|
+
);
|
|
5826
|
+
const associationIds = associations.rows.map((row) => row.id);
|
|
5827
|
+
if (!associationIds.length) return { associationIds: [], touches: 0 };
|
|
5828
|
+
for (const associationId of associationIds) {
|
|
5829
|
+
await client.query(
|
|
5830
|
+
`INSERT INTO analytics_candidate_promotions(
|
|
5831
|
+
id,site_id,namespace_id,association_id,person_id,evidence_kind,
|
|
5832
|
+
evidence_ref_hmac,promoted_by,promoted_at
|
|
5833
|
+
) VALUES($1,$2,$3,$4,$5,$6,$7,'system',$8::timestamptz)
|
|
5834
|
+
ON CONFLICT(association_id,person_id) DO NOTHING`,
|
|
5835
|
+
[
|
|
5836
|
+
randomUUID2(),
|
|
5837
|
+
input.siteId,
|
|
5838
|
+
input.namespaceId,
|
|
5839
|
+
associationId,
|
|
5840
|
+
input.personId,
|
|
5841
|
+
input.candidateEvidenceKind,
|
|
5842
|
+
input.evidenceRefHmac,
|
|
5843
|
+
input.occurredAt
|
|
5844
|
+
]
|
|
5845
|
+
);
|
|
5846
|
+
}
|
|
5847
|
+
const touches = await client.query(
|
|
5848
|
+
`UPDATE analytics_attribution_touches touch SET person_id=$3
|
|
5849
|
+
FROM analytics_candidate_attribution_touches candidate_touch
|
|
5850
|
+
WHERE candidate_touch.site_id=$1 AND candidate_touch.namespace_id=$2
|
|
5851
|
+
AND candidate_touch.association_id=ANY($4::uuid[])
|
|
5852
|
+
AND candidate_touch.touch_id=touch.id AND touch.site_id=$1 AND touch.person_id IS NULL`,
|
|
5853
|
+
[input.siteId, input.namespaceId, input.personId, associationIds]
|
|
5854
|
+
);
|
|
5855
|
+
return { associationIds, touches: touches.rowCount ?? 0 };
|
|
5856
|
+
}
|
|
5857
|
+
async function recordAnalyticsIdentityPromotionReceipt(client, input) {
|
|
5858
|
+
const evidenceRefHmac = identityHmac("promotion_evidence", `${input.evidenceKind}:${input.evidenceRef}`);
|
|
5859
|
+
const personRefHmac = identityHmac("promotion_person", input.personId);
|
|
5860
|
+
const idempotencyKey = identityHmac(
|
|
5861
|
+
"promotion_receipt",
|
|
5862
|
+
`${input.siteId}:${input.personId}:${input.evidenceKind}:${input.evidenceRef}`
|
|
5863
|
+
);
|
|
5864
|
+
await client.query(
|
|
5865
|
+
`INSERT INTO analytics_identity_promotion_receipts(
|
|
5866
|
+
id,site_id,namespace_id,canonical_person_id,canonical_person_ref_hmac,
|
|
5867
|
+
evidence_kind,evidence_ref_hmac,prior_person_ids,prior_identity_node_ids,
|
|
5868
|
+
candidate_association_ids,affected_history_counts,schema_version,idempotency_key,promoted_at
|
|
5869
|
+
) VALUES($1,$2,$3,$4,$5,$6,$7,$8::uuid[],$9::uuid[],$10::uuid[],$11::jsonb,
|
|
5870
|
+
'xray.identity-promotion.v1',$12,$13::timestamptz)
|
|
5871
|
+
ON CONFLICT(site_id,idempotency_key) DO NOTHING`,
|
|
5872
|
+
[
|
|
5873
|
+
randomUUID2(),
|
|
5874
|
+
input.siteId,
|
|
5875
|
+
input.namespaceId,
|
|
5876
|
+
input.personId,
|
|
5877
|
+
personRefHmac,
|
|
5878
|
+
input.evidenceKind,
|
|
5879
|
+
evidenceRefHmac,
|
|
5880
|
+
input.priorPersonIds,
|
|
5881
|
+
input.priorIdentityNodeIds,
|
|
5882
|
+
input.candidateAssociationIds,
|
|
5883
|
+
JSON.stringify({ ...input.history, candidateTouches: input.candidateTouches }),
|
|
5884
|
+
idempotencyKey,
|
|
5885
|
+
input.occurredAt
|
|
5886
|
+
]
|
|
5887
|
+
);
|
|
5888
|
+
}
|
|
5889
|
+
async function completeAnalyticsIdentityPromotion(client, input) {
|
|
5890
|
+
const occurredAt = input.occurredAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
5891
|
+
const history = await backfillAnalyticsConfirmedHistory(client, input);
|
|
5892
|
+
const evidenceRefHmac = identityHmac("promotion_evidence", `${input.evidenceKind}:${input.evidenceRef}`);
|
|
5893
|
+
const candidates = await promoteAnalyticsCandidatesForConfirmedPerson(client, {
|
|
5894
|
+
siteId: input.siteId,
|
|
5895
|
+
namespaceId: input.namespaceId,
|
|
5896
|
+
personId: input.personId,
|
|
5897
|
+
candidateEvidenceKind: input.candidateEvidenceKind,
|
|
5898
|
+
evidenceRefHmac,
|
|
5899
|
+
occurredAt
|
|
5900
|
+
});
|
|
5901
|
+
await recordAnalyticsIdentityPromotionReceipt(client, {
|
|
5902
|
+
...input,
|
|
5903
|
+
history,
|
|
5904
|
+
candidateAssociationIds: candidates.associationIds,
|
|
5905
|
+
candidateTouches: candidates.touches,
|
|
5906
|
+
occurredAt
|
|
5907
|
+
});
|
|
5109
5908
|
}
|
|
5110
5909
|
async function linkAnalyticsIdentityInTransaction(client, input) {
|
|
5111
5910
|
const stable = input.customerId || input.orderId || input.email || input.phone;
|
|
@@ -5128,12 +5927,18 @@ async function linkAnalyticsIdentityInTransaction(client, input) {
|
|
|
5128
5927
|
signals: tombstoneSignals
|
|
5129
5928
|
});
|
|
5130
5929
|
const crmPersonRef = `XRay::${identityHmac("person_ref", stable).slice(0, 24)}`;
|
|
5131
|
-
const
|
|
5930
|
+
const matchSignals = [
|
|
5931
|
+
...tombstoneSignals,
|
|
5932
|
+
...input.visitorId ? [{ kind: "visitor_id", valueHmac: identityHmac("visitor_id", input.visitorId) }] : [],
|
|
5933
|
+
...input.sessionId ? [{ kind: "session_id", valueHmac: identityHmac("session_id", input.sessionId) }] : []
|
|
5934
|
+
];
|
|
5935
|
+
const resolution = await resolveAndMergeConfirmedAnalyticsPerson(client, {
|
|
5132
5936
|
siteId: input.siteId,
|
|
5133
5937
|
namespaceId,
|
|
5134
5938
|
crmPersonRef,
|
|
5135
|
-
signals:
|
|
5939
|
+
signals: matchSignals
|
|
5136
5940
|
});
|
|
5941
|
+
const personId = resolution.personId;
|
|
5137
5942
|
const signals = [];
|
|
5138
5943
|
if (input.visitorId)
|
|
5139
5944
|
signals.push({ kind: "visitor_id", value: input.visitorId, evidence: "pixel_identify", confidence: 0.98 });
|
|
@@ -5149,6 +5954,7 @@ async function linkAnalyticsIdentityInTransaction(client, input) {
|
|
|
5149
5954
|
signals.push({ kind: "customer_id", value: `order:${input.orderId}`, evidence: "order_id", confidence: 1 });
|
|
5150
5955
|
for (const [platform, clickId] of Object.entries(sanitizeClickIds(input.clickIds)))
|
|
5151
5956
|
signals.push({ kind: "click_id", value: `${platform}:${clickId}`, evidence: platform, confidence: 0.9 });
|
|
5957
|
+
const identityNodeIds = [];
|
|
5152
5958
|
for (const signal of signals) {
|
|
5153
5959
|
const node = await client.query(
|
|
5154
5960
|
`INSERT INTO analytics_identity_nodes(id, site_id, namespace_id, kind, value_hmac) VALUES ($1,$2,$3,$4,$5)
|
|
@@ -5156,6 +5962,7 @@ async function linkAnalyticsIdentityInTransaction(client, input) {
|
|
|
5156
5962
|
namespace_id=COALESCE(analytics_identity_nodes.namespace_id,EXCLUDED.namespace_id) RETURNING id`,
|
|
5157
5963
|
[randomUUID2(), input.siteId, namespaceId, signal.kind, identityHmac(signal.kind, signal.value)]
|
|
5158
5964
|
);
|
|
5965
|
+
identityNodeIds.push(node.rows[0].id);
|
|
5159
5966
|
await client.query(
|
|
5160
5967
|
`INSERT INTO analytics_identity_edges(id, site_id, namespace_id, person_id, identity_node_id, evidence_kind, confidence)
|
|
5161
5968
|
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
|
@@ -5165,11 +5972,18 @@ async function linkAnalyticsIdentityInTransaction(client, input) {
|
|
|
5165
5972
|
[randomUUID2(), input.siteId, namespaceId, personId, node.rows[0].id, signal.evidence, signal.confidence]
|
|
5166
5973
|
);
|
|
5167
5974
|
}
|
|
5168
|
-
await
|
|
5975
|
+
await completeAnalyticsIdentityPromotion(client, {
|
|
5169
5976
|
siteId: input.siteId,
|
|
5977
|
+
namespaceId,
|
|
5170
5978
|
personId,
|
|
5979
|
+
evidenceKind: input.customerId ? "authenticated_login" : "browser_identify",
|
|
5980
|
+
candidateEvidenceKind: input.email ? "verified_email" : input.phone ? "verified_phone" : "authenticated_user",
|
|
5981
|
+
evidenceRef: stable,
|
|
5982
|
+
priorPersonIds: resolution.mergedPersonIds,
|
|
5983
|
+
priorIdentityNodeIds: identityNodeIds,
|
|
5171
5984
|
visitorId: input.visitorId,
|
|
5172
|
-
sessionId: input.sessionId
|
|
5985
|
+
sessionId: input.sessionId,
|
|
5986
|
+
occurredAt: input.occurredAt
|
|
5173
5987
|
});
|
|
5174
5988
|
if (input.customerId) {
|
|
5175
5989
|
await writeAnalyticsConfirmedIdentityProfile(client, {
|
|
@@ -5205,12 +6019,18 @@ async function linkAnalyticsFormIdentity(input) {
|
|
|
5205
6019
|
siteId: input.siteId,
|
|
5206
6020
|
signals: deterministicSignals
|
|
5207
6021
|
});
|
|
5208
|
-
const
|
|
6022
|
+
const matchSignals = [
|
|
6023
|
+
...deterministicSignals,
|
|
6024
|
+
...input.visitorId ? [{ kind: "visitor_id", valueHmac: identityHmac("visitor_id", input.visitorId) }] : [],
|
|
6025
|
+
...input.sessionId ? [{ kind: "session_id", valueHmac: identityHmac("session_id", input.sessionId) }] : []
|
|
6026
|
+
];
|
|
6027
|
+
const resolution = await resolveAndMergeConfirmedAnalyticsPerson(client, {
|
|
5209
6028
|
siteId: input.siteId,
|
|
5210
6029
|
namespaceId,
|
|
5211
6030
|
crmPersonRef: input.crmPersonRef,
|
|
5212
|
-
signals:
|
|
6031
|
+
signals: matchSignals
|
|
5213
6032
|
});
|
|
6033
|
+
const personId = resolution.personId;
|
|
5214
6034
|
const signals = [];
|
|
5215
6035
|
if (input.visitorId)
|
|
5216
6036
|
signals.push({
|
|
@@ -5250,6 +6070,7 @@ async function linkAnalyticsFormIdentity(input) {
|
|
|
5250
6070
|
confidence: 0.9
|
|
5251
6071
|
});
|
|
5252
6072
|
}
|
|
6073
|
+
const identityNodeIds = [];
|
|
5253
6074
|
for (const signal of signals) {
|
|
5254
6075
|
const node = await client.query(
|
|
5255
6076
|
`INSERT INTO analytics_identity_nodes(id, site_id, namespace_id, kind, value_hmac) VALUES ($1,$2,$3,$4,$5)
|
|
@@ -5263,6 +6084,7 @@ async function linkAnalyticsFormIdentity(input) {
|
|
|
5263
6084
|
identityHmac(signal.kind, signal.value)
|
|
5264
6085
|
]
|
|
5265
6086
|
);
|
|
6087
|
+
identityNodeIds.push(node.rows[0].id);
|
|
5266
6088
|
await client.query(
|
|
5267
6089
|
`INSERT INTO analytics_identity_edges(id, site_id, namespace_id, person_id, identity_node_id, evidence_kind, confidence)
|
|
5268
6090
|
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
|
@@ -5285,14 +6107,18 @@ async function linkAnalyticsFormIdentity(input) {
|
|
|
5285
6107
|
"UPDATE analytics_form_submissions SET person_id = $1 WHERE id = $2 AND site_id = $3",
|
|
5286
6108
|
[personId, input.submissionId, input.siteId]
|
|
5287
6109
|
);
|
|
5288
|
-
|
|
5289
|
-
|
|
5290
|
-
|
|
5291
|
-
|
|
5292
|
-
|
|
5293
|
-
|
|
5294
|
-
|
|
5295
|
-
|
|
6110
|
+
await completeAnalyticsIdentityPromotion(client, {
|
|
6111
|
+
siteId: input.siteId,
|
|
6112
|
+
namespaceId,
|
|
6113
|
+
personId,
|
|
6114
|
+
evidenceKind: "form_fill",
|
|
6115
|
+
candidateEvidenceKind: input.email ? "verified_email" : input.phone ? "verified_phone" : "authenticated_user",
|
|
6116
|
+
evidenceRef: input.submissionId ?? input.crmPersonRef,
|
|
6117
|
+
priorPersonIds: resolution.mergedPersonIds,
|
|
6118
|
+
priorIdentityNodeIds: identityNodeIds,
|
|
6119
|
+
visitorId: input.visitorId,
|
|
6120
|
+
sessionId: input.sessionId
|
|
6121
|
+
});
|
|
5296
6122
|
await writeAnalyticsConfirmedIdentityProfile(client, {
|
|
5297
6123
|
siteId: input.siteId,
|
|
5298
6124
|
personId,
|
|
@@ -5334,6 +6160,19 @@ async function enrichAnalyticsExistingCrmIdentity(input) {
|
|
|
5334
6160
|
siteId: input.siteId,
|
|
5335
6161
|
signals: signals.map((signal) => ({ kind: signal.kind, valueHmac: identityHmac(signal.kind, signal.value) }))
|
|
5336
6162
|
});
|
|
6163
|
+
const resolution = await resolveAndMergeConfirmedAnalyticsPerson(client, {
|
|
6164
|
+
siteId: input.siteId,
|
|
6165
|
+
namespaceId: person.namespace_id,
|
|
6166
|
+
crmPersonRef: input.crmPersonRef,
|
|
6167
|
+
signals: signals.map((signal) => ({ kind: signal.kind, valueHmac: identityHmac(signal.kind, signal.value) }))
|
|
6168
|
+
});
|
|
6169
|
+
const personId = resolution.personId;
|
|
6170
|
+
const canonical = await client.query(
|
|
6171
|
+
"SELECT public_ref FROM analytics_people WHERE id=$1 AND site_id=$2",
|
|
6172
|
+
[personId, input.siteId]
|
|
6173
|
+
);
|
|
6174
|
+
const canonicalPublicRef = canonical.rows[0]?.public_ref ?? person.public_ref;
|
|
6175
|
+
const identityNodeIds = [];
|
|
5337
6176
|
for (const signal of signals) {
|
|
5338
6177
|
const node = await client.query(
|
|
5339
6178
|
`INSERT INTO analytics_identity_nodes(id,site_id,namespace_id,kind,value_hmac) VALUES($1,$2,$3,$4,$5)
|
|
@@ -5341,25 +6180,36 @@ async function enrichAnalyticsExistingCrmIdentity(input) {
|
|
|
5341
6180
|
namespace_id=COALESCE(analytics_identity_nodes.namespace_id,EXCLUDED.namespace_id) RETURNING id`,
|
|
5342
6181
|
[randomUUID2(), input.siteId, person.namespace_id, signal.kind, identityHmac(signal.kind, signal.value)]
|
|
5343
6182
|
);
|
|
6183
|
+
identityNodeIds.push(node.rows[0].id);
|
|
5344
6184
|
await client.query(
|
|
5345
6185
|
`INSERT INTO analytics_identity_edges(id,site_id,namespace_id,person_id,identity_node_id,evidence_kind,confidence)
|
|
5346
6186
|
VALUES($1,$2,$3,$4,$5,'crm_contact',$6)
|
|
5347
6187
|
ON CONFLICT(person_id,identity_node_id,evidence_kind) DO UPDATE SET last_seen_at=now(),
|
|
5348
6188
|
confidence=greatest(analytics_identity_edges.confidence,EXCLUDED.confidence)`,
|
|
5349
|
-
[randomUUID2(), input.siteId, person.namespace_id,
|
|
6189
|
+
[randomUUID2(), input.siteId, person.namespace_id, personId, node.rows[0].id, signal.confidence]
|
|
5350
6190
|
);
|
|
5351
6191
|
}
|
|
6192
|
+
await completeAnalyticsIdentityPromotion(client, {
|
|
6193
|
+
siteId: input.siteId,
|
|
6194
|
+
namespaceId: person.namespace_id,
|
|
6195
|
+
personId,
|
|
6196
|
+
evidenceKind: "crm_identity",
|
|
6197
|
+
candidateEvidenceKind: "crm_contact",
|
|
6198
|
+
evidenceRef: input.crmPersonRef,
|
|
6199
|
+
priorPersonIds: resolution.mergedPersonIds,
|
|
6200
|
+
priorIdentityNodeIds: identityNodeIds
|
|
6201
|
+
});
|
|
5352
6202
|
await writeAnalyticsConfirmedIdentityProfile(client, {
|
|
5353
6203
|
siteId: input.siteId,
|
|
5354
|
-
personId
|
|
5355
|
-
consentSubjectRefs: [
|
|
6204
|
+
personId,
|
|
6205
|
+
consentSubjectRefs: [canonicalPublicRef],
|
|
5356
6206
|
evidenceType: "crm_contact",
|
|
5357
6207
|
displayName: input.displayName,
|
|
5358
6208
|
email: input.email,
|
|
5359
6209
|
phone: input.phone
|
|
5360
6210
|
});
|
|
5361
6211
|
await client.query("COMMIT");
|
|
5362
|
-
return { personId
|
|
6212
|
+
return { personId, linkedSignals: signals.length };
|
|
5363
6213
|
} catch (error) {
|
|
5364
6214
|
await client.query("ROLLBACK");
|
|
5365
6215
|
throw error;
|
|
@@ -5426,12 +6276,14 @@ async function createAnalyticsCrmImport(input) {
|
|
|
5426
6276
|
const db = getAnalyticsPool();
|
|
5427
6277
|
await requireEditor(db, input.siteId, input.userId);
|
|
5428
6278
|
const client = await db.connect();
|
|
5429
|
-
const id = randomUUID2();
|
|
6279
|
+
const id = input.id ?? randomUUID2();
|
|
5430
6280
|
try {
|
|
5431
6281
|
await client.query("BEGIN");
|
|
5432
|
-
await client.query(
|
|
6282
|
+
const inserted = await client.query(
|
|
5433
6283
|
`INSERT INTO analytics_crm_imports(id, site_id, requested_by_user_id, source_system, filename, accepted_count, rejected_count)
|
|
5434
|
-
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
|
6284
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
|
6285
|
+
ON CONFLICT(id) DO NOTHING
|
|
6286
|
+
RETURNING id,status,accepted_count,rejected_count`,
|
|
5435
6287
|
[
|
|
5436
6288
|
id,
|
|
5437
6289
|
input.siteId,
|
|
@@ -5442,6 +6294,18 @@ async function createAnalyticsCrmImport(input) {
|
|
|
5442
6294
|
input.rejectedCount
|
|
5443
6295
|
]
|
|
5444
6296
|
);
|
|
6297
|
+
if (!inserted.rowCount) {
|
|
6298
|
+
const existing = await client.query(
|
|
6299
|
+
`SELECT id,status,accepted_count,rejected_count
|
|
6300
|
+
FROM analytics_crm_imports
|
|
6301
|
+
WHERE id=$1 AND site_id=$2 AND requested_by_user_id=$3`,
|
|
6302
|
+
[id, input.siteId, input.userId]
|
|
6303
|
+
);
|
|
6304
|
+
const row = existing.rows[0];
|
|
6305
|
+
if (!row) throw new AnalyticsRepositoryError("analytics_crm_import_idempotency_conflict", "Import idempotency receipt belongs to another request.", 409);
|
|
6306
|
+
await client.query("COMMIT");
|
|
6307
|
+
return { id: row.id, status: row.status, acceptedCount: Number(row.accepted_count), rejectedCount: Number(row.rejected_count) };
|
|
6308
|
+
}
|
|
5445
6309
|
for (const row of input.rows) {
|
|
5446
6310
|
await client.query(
|
|
5447
6311
|
`INSERT INTO analytics_crm_import_rows(id, import_id, crm_person_ref, payload_ciphertext)
|
|
@@ -5546,6 +6410,7 @@ async function deferAnalyticsCrmImportRow(rowId, importId, attempts, errorCode)
|
|
|
5546
6410
|
}
|
|
5547
6411
|
async function createAnalyticsActivationDestination(input) {
|
|
5548
6412
|
assertSafeAnalyticsEventMapping(input.eventMapping);
|
|
6413
|
+
const eventMapping = normalizeAnalyticsActivationEventMapping(input.eventMapping);
|
|
5549
6414
|
const externalDatasetId = assertProviderDestination(input.platform, input.externalDatasetId);
|
|
5550
6415
|
const operatingAccountId = input.operatingAccountId?.trim();
|
|
5551
6416
|
if (input.platform === "google" && !operatingAccountId) {
|
|
@@ -5566,8 +6431,8 @@ async function createAnalyticsActivationDestination(input) {
|
|
|
5566
6431
|
const db = getAnalyticsPool();
|
|
5567
6432
|
await requireEditor(db, input.siteId, input.userId);
|
|
5568
6433
|
const result = await db.query(
|
|
5569
|
-
`INSERT INTO analytics_activation_destinations(id, site_id, platform, name, connection_ref, external_dataset_id, event_mapping, provider_config, created_by_user_id)
|
|
5570
|
-
VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9) RETURNING *`,
|
|
6434
|
+
`INSERT INTO analytics_activation_destinations(id, site_id, platform, name, connection_ref, external_dataset_id, event_mapping, provider_config, mapping_version, automatic_delivery_enabled, created_by_user_id)
|
|
6435
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9,false,$10) RETURNING *`,
|
|
5571
6436
|
[
|
|
5572
6437
|
randomUUID2(),
|
|
5573
6438
|
input.siteId,
|
|
@@ -5575,8 +6440,9 @@ async function createAnalyticsActivationDestination(input) {
|
|
|
5575
6440
|
input.name.trim(),
|
|
5576
6441
|
input.connectionRef.trim(),
|
|
5577
6442
|
externalDatasetId,
|
|
5578
|
-
JSON.stringify(
|
|
6443
|
+
JSON.stringify(eventMapping),
|
|
5579
6444
|
JSON.stringify(operatingAccountId ? { operatingAccountId } : {}),
|
|
6445
|
+
eventMapping.schemaVersion,
|
|
5580
6446
|
input.userId
|
|
5581
6447
|
]
|
|
5582
6448
|
);
|
|
@@ -5610,6 +6476,13 @@ async function archiveAnalyticsActivationDestination(input) {
|
|
|
5610
6476
|
404
|
|
5611
6477
|
);
|
|
5612
6478
|
}
|
|
6479
|
+
function activationMappingAllows(eventMapping, sourceEvent) {
|
|
6480
|
+
try {
|
|
6481
|
+
return analyticsActivationMappingEligible(eventMapping, sourceEvent);
|
|
6482
|
+
} catch {
|
|
6483
|
+
return false;
|
|
6484
|
+
}
|
|
6485
|
+
}
|
|
5613
6486
|
function safeActivationErrorCategory(error) {
|
|
5614
6487
|
const message = error instanceof Error ? error.message.toLowerCase() : "";
|
|
5615
6488
|
if (/401|403|authori[sz]/.test(message)) return "activation_authorization_failed";
|
|
@@ -5626,7 +6499,7 @@ async function loadActivationDestinationForEditor(input) {
|
|
|
5626
6499
|
await requireEditor(db, input.siteId, input.userId);
|
|
5627
6500
|
const result = await db.query(
|
|
5628
6501
|
`SELECT id, site_id, platform, connection_ref, external_dataset_id,
|
|
5629
|
-
event_mapping, provider_config, mapping_version, readiness
|
|
6502
|
+
event_mapping, provider_config, mapping_version, readiness, automatic_delivery_enabled
|
|
5630
6503
|
FROM analytics_activation_destinations
|
|
5631
6504
|
WHERE id=$1 AND site_id=$2 AND status='active'`,
|
|
5632
6505
|
[input.destinationId, input.siteId]
|
|
@@ -5854,7 +6727,8 @@ async function retryAnalyticsActivationJob(input) {
|
|
|
5854
6727
|
dead_lettered_at=NULL, last_error_code=NULL, response_category=NULL
|
|
5855
6728
|
FROM analytics_activation_destinations d
|
|
5856
6729
|
WHERE j.id=$1 AND d.id=j.destination_id AND d.site_id=$2
|
|
5857
|
-
AND d.status='active' AND d.
|
|
6730
|
+
AND d.status='active' AND d.automatic_delivery_enabled=true
|
|
6731
|
+
AND d.readiness IN ('verified','live','degraded')
|
|
5858
6732
|
AND j.status IN ('failed','expired')
|
|
5859
6733
|
RETURNING j.id, j.status, j.attempts, j.next_attempt_at`,
|
|
5860
6734
|
[input.jobId, input.siteId]
|
|
@@ -5944,12 +6818,14 @@ async function pollAnalyticsActivationDiagnostics(input) {
|
|
|
5944
6818
|
async function queueAnalyticsActivation(input) {
|
|
5945
6819
|
const db = getAnalyticsPool();
|
|
5946
6820
|
const destinations = await db.query(
|
|
5947
|
-
`SELECT id FROM analytics_activation_destinations
|
|
5948
|
-
WHERE site_id = $1 AND status = 'active' AND
|
|
6821
|
+
`SELECT id,event_mapping FROM analytics_activation_destinations
|
|
6822
|
+
WHERE site_id = $1 AND status = 'active' AND automatic_delivery_enabled=true
|
|
6823
|
+
AND readiness IN ('verified','live')`,
|
|
5949
6824
|
[input.siteId]
|
|
5950
6825
|
);
|
|
5951
6826
|
let queued = 0;
|
|
5952
6827
|
for (const destination of destinations.rows) {
|
|
6828
|
+
if (!activationMappingAllows(destination.event_mapping, input.conversionKind)) continue;
|
|
5953
6829
|
const result = await db.query(
|
|
5954
6830
|
`INSERT INTO analytics_activation_jobs(id, destination_id, conversion_id, person_id, payload_ciphertext)
|
|
5955
6831
|
VALUES ($1,$2,$3,$4,$5) ON CONFLICT(destination_id, conversion_id) DO NOTHING`,
|
|
@@ -6217,17 +7093,18 @@ async function refreshAnalyticsDailyRollupsIfDue(now = /* @__PURE__ */ new Date(
|
|
|
6217
7093
|
lockClient.release();
|
|
6218
7094
|
}
|
|
6219
7095
|
}
|
|
6220
|
-
function
|
|
7096
|
+
function analyticsCsvCell(value) {
|
|
6221
7097
|
const text = value == null ? "" : String(value);
|
|
6222
|
-
|
|
7098
|
+
const neutralized = /^[=+\-@\t\r]/.test(text) ? `'${text}` : text;
|
|
7099
|
+
return /[\n\r,\"]/.test(neutralized) ? `"${neutralized.replaceAll('"', '""')}"` : neutralized;
|
|
6223
7100
|
}
|
|
6224
7101
|
function rowsToCsv(rows) {
|
|
6225
7102
|
const columns = [...new Set(rows.flatMap((row) => Object.keys(row)))];
|
|
6226
7103
|
if (!columns.length) return "";
|
|
6227
7104
|
return [
|
|
6228
|
-
columns.join(","),
|
|
7105
|
+
columns.map((column) => analyticsCsvCell(column)).join(","),
|
|
6229
7106
|
...rows.map(
|
|
6230
|
-
(row) => columns.map((column) =>
|
|
7107
|
+
(row) => columns.map((column) => analyticsCsvCell(row[column])).join(",")
|
|
6231
7108
|
)
|
|
6232
7109
|
].join("\n");
|
|
6233
7110
|
}
|
|
@@ -6364,6 +7241,7 @@ export {
|
|
|
6364
7241
|
resolveAnalyticsMaskedIdentityProfile,
|
|
6365
7242
|
AnalyticsProviderRegistryError,
|
|
6366
7243
|
getAnalyticsProviderDefinition,
|
|
7244
|
+
normalizeAnalyticsActivationEventMapping,
|
|
6367
7245
|
assertConnectionSupportsProvider,
|
|
6368
7246
|
buildProviderDeliveryRequest,
|
|
6369
7247
|
normalizeProviderReceipt,
|
|
@@ -6451,5 +7329,6 @@ export {
|
|
|
6451
7329
|
analyticsHealth,
|
|
6452
7330
|
refreshAnalyticsDailyRollups,
|
|
6453
7331
|
refreshAnalyticsDailyRollupsIfDue,
|
|
7332
|
+
analyticsCsvCell,
|
|
6454
7333
|
createAnalyticsExport
|
|
6455
7334
|
};
|