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.
@@ -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 currency = boundedString(payload.currency, 3)?.toUpperCase();
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 && currency ? { conversionValue: valueMinor / 100, currency } : {},
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 currency = boundedString(payload.currency, 3)?.toUpperCase();
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
- ...currency ? { currency } : {},
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
- ...eventId ? { eventId } : {},
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 currency = boundedString(payload.currency, 3)?.toUpperCase();
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 && currency ? { properties: { value: valueMinor / 100, currency, ...orderId ? { orderId } : {} } } : orderId ? { properties: { orderId } } : {}
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 currency = boundedString(payload.currency, 3)?.toUpperCase();
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 && currency ? { value: valueMinor / 100, currency } : {},
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 assertSafeAnalyticsEventMapping(eventMapping) {
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
- return input.eventMapping?.[canonical] || canonical;
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, input.payload);
851
+ return buildMetaDeliveryRequest(destinationId, eventName, payload);
757
852
  }
758
853
  if (input.platform === "google") {
759
- return buildGoogleDeliveryRequest(destinationId, input.payload);
854
+ return buildGoogleDeliveryRequest(destinationId, payload);
760
855
  }
761
856
  if (input.platform === "tiktok") {
762
- return buildTikTokDeliveryRequest(destinationId, eventName, input.payload);
857
+ return buildTikTokDeliveryRequest(destinationId, eventName, payload);
763
858
  }
764
- return buildRedditDeliveryRequest(destinationId, eventName, input.payload);
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 && (record.accepted === true || record.success === true || record.providerAccepted === true || record.acceptedForProcessing === true || (eventsReceived ?? 0) > 0),
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 readiness IN ('verified','live')`,
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 ${query.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 values = [siteId, filters.start, filters.end];
4113
- const touchClauses = [
4114
- "c.site_id = $1",
4115
- "c.occurred_at >= $2::timestamptz",
4116
- "c.occurred_at < $3::timestamptz"
4117
- ];
4118
- const addTouch = (sql, value) => {
4119
- values.push(value);
4120
- touchClauses.push(sql.replace("?", `$${values.length}`));
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
- if (filters.pixelIds?.length)
4123
- addTouch("touch.pixel_id = ANY(?::uuid[])", filters.pixelIds);
4124
- if (filters.hostnames?.length)
4125
- addTouch("touch.hostname = ANY(?::text[])", filters.hostnames);
4126
- if (filters.source)
4127
- addTouch(`COALESCE(touch.source, '(direct)') = ?`, filters.source);
4128
- if (filters.medium)
4129
- addTouch(`COALESCE(touch.medium, '(none)') = ?`, filters.medium);
4130
- if (filters.campaign)
4131
- addTouch(`COALESCE(touch.campaign, '(none)') = ?`, filters.campaign);
4132
- if (filters.channelFamily)
4133
- addTouch(
4134
- `COALESCE(touch.channel_family, 'other') = ?`,
4135
- filters.channelFamily
4136
- );
4137
- if (filters.platform)
4138
- addTouch(
4139
- `COALESCE(touch.platform, touch.source, '(direct)') = ?`,
4140
- filters.platform
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
- ...row,
4196
- conversions,
4197
- conversionRate: row.sessions > 0 ? conversions / row.sessions : 0,
4198
- revenueMinor: Number(outcome?.revenue_minor ?? 0)
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
- ORDER BY p.first_seen_at ASC,p.id ASC
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 personId = await resolveAndMergeConfirmedAnalyticsPerson(client, {
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: tombstoneSignals
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 backfillAnalyticsConfirmedHistory(client, {
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 personId = await resolveAndMergeConfirmedAnalyticsPerson(client, {
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: deterministicSignals
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
- if (input.visitorId || input.sessionId) {
5289
- await backfillAnalyticsConfirmedHistory(client, {
5290
- siteId: input.siteId,
5291
- personId,
5292
- visitorId: input.visitorId,
5293
- sessionId: input.sessionId
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, person.id, node.rows[0].id, signal.confidence]
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: person.id,
5355
- consentSubjectRefs: [person.public_ref],
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: person.id, linkedSignals: signals.length };
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(input.eventMapping ?? {}),
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.readiness IN ('verified','live','degraded')
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 readiness IN ('verified','live')`,
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 csvCell(value) {
7096
+ function analyticsCsvCell(value) {
6221
7097
  const text = value == null ? "" : String(value);
6222
- return /[\n\r,\"]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
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) => csvCell(row[column])).join(",")
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
  };