@canonry/canonry 4.133.3 → 4.134.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.
Files changed (28) hide show
  1. package/README.md +0 -13
  2. package/assets/agent-workspace/skills/canonry/references/aeo-analysis.md +3 -2
  3. package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +32 -3
  4. package/assets/assets/{AuditHistoryPanel-DTHQGvkW.js → AuditHistoryPanel-BW7LVMSZ.js} +1 -1
  5. package/assets/assets/{BacklinksPage-DuPI3wt9.js → BacklinksPage-DU65axM1.js} +1 -1
  6. package/assets/assets/{ChartPrimitives-T8mxOObc.js → ChartPrimitives-CUZJ7nUb.js} +1 -1
  7. package/assets/assets/{HistoryPage-T1W40xKG.js → HistoryPage-BswdEXC5.js} +1 -1
  8. package/assets/assets/ProjectPage-CttTuKKo.js +8 -0
  9. package/assets/assets/{RunRow-nHr4wW9B.js → RunRow-CBxbfubI.js} +1 -1
  10. package/assets/assets/{RunsPage-Cbusy_84.js → RunsPage-BQxA05ua.js} +1 -1
  11. package/assets/assets/{SettingsPage-BVcbl4gn.js → SettingsPage-krEusjc9.js} +1 -1
  12. package/assets/assets/{TrafficPage-CZ4NTWI6.js → TrafficPage-DMhcA9lg.js} +1 -1
  13. package/assets/assets/{TrafficSourceDetailPage-xW-KvZY1.js → TrafficSourceDetailPage-Dm0Gj0HS.js} +1 -1
  14. package/assets/assets/{arrow-left-flskyFPK.js → arrow-left-_1YaUPbl.js} +1 -1
  15. package/assets/assets/{extract-error-message-BHfCTm0H.js → extract-error-message-D4XKG37U.js} +1 -1
  16. package/assets/assets/{index-CQzGDGMB.js → index-V1C3c7nO.js} +100 -100
  17. package/assets/assets/{trash-2-LGfqtSzZ.js → trash-2-D3LWIGDJ.js} +1 -1
  18. package/assets/index.html +1 -1
  19. package/dist/{chunk-A6BYMSZL.js → chunk-BSU2MI3Y.js} +79 -4
  20. package/dist/{chunk-YRRQFOAH.js → chunk-CF5WTWXI.js} +257 -2
  21. package/dist/{chunk-HENMGNLT.js → chunk-DVJGFY3J.js} +684 -74
  22. package/dist/{chunk-6ON6GUAL.js → chunk-NWVPRV7A.js} +68 -6
  23. package/dist/cli.js +113 -5
  24. package/dist/index.js +4 -4
  25. package/dist/{intelligence-service-OSLFG5S2.js → intelligence-service-NO4FJUYD.js} +2 -2
  26. package/dist/mcp.js +2 -2
  27. package/package.json +9 -9
  28. package/assets/assets/ProjectPage-DYwM8-Rp.js +0 -8
@@ -19,6 +19,9 @@ import {
19
19
  AdsHistoricalCampaignRollupStatuses,
20
20
  AdsInsightLevels,
21
21
  AdsIntegrityDecisions,
22
+ AdsLiveDeliveryBases,
23
+ AdsLiveEntityTypes,
24
+ AdsLivePresences,
22
25
  AdsOperationKinds,
23
26
  AdsOperationStates,
24
27
  AdsOperationStepStates,
@@ -96,6 +99,8 @@ import {
96
99
  adsImageUploadRequestSchema,
97
100
  adsInsightLevelSchema,
98
101
  adsInsightsResponseSchema,
102
+ adsLiveDeliveryDtoSchema,
103
+ adsLiveDeliveryQuerySchema,
99
104
  adsOperationReconcileResponseSchema,
100
105
  adsOperationResponseSchema,
101
106
  adsOperationStepDtoSchema,
@@ -183,6 +188,7 @@ import {
183
188
  discoverySessionDetailDtoSchema,
184
189
  discoverySessionDtoSchema,
185
190
  doctorReportSchema,
191
+ dollarsToMicros,
186
192
  domainClassificationsResponseDtoSchema,
187
193
  effectiveBrandNames,
188
194
  effectiveDomains,
@@ -199,9 +205,11 @@ import {
199
205
  formatDateRange,
200
206
  formatDeltaCopy,
201
207
  formatIsoDate,
208
+ formatIsoDateInTimeZone,
202
209
  formatNumber,
203
210
  formatRatio,
204
211
  formatWindowCountDelta,
212
+ ga4AiReferralDailyDtoSchema,
205
213
  ga4AiReferralHistoryEntrySchema,
206
214
  ga4SessionHistoryEntrySchema,
207
215
  ga4SocialReferralHistoryEntrySchema,
@@ -242,6 +250,7 @@ import {
242
250
  internalError,
243
251
  isBrowserProvider,
244
252
  isReadOnlyKey,
253
+ isoDateDaysBeforeInTimeZone,
245
254
  keywordDtoSchema,
246
255
  keywordGenerateRequestSchema,
247
256
  latestProjectRunDtoSchema,
@@ -356,7 +365,7 @@ import {
356
365
  wordpressSchemaDeployResultDtoSchema,
357
366
  wordpressSchemaStatusResultDtoSchema,
358
367
  wordpressStatusDtoSchema
359
- } from "./chunk-YRRQFOAH.js";
368
+ } from "./chunk-CF5WTWXI.js";
360
369
 
361
370
  // src/intelligence-service.ts
362
371
  import { eq as eq44, desc as desc21, asc as asc9, and as and35, ne as ne6, or as or10, inArray as inArray15, gte as gte12, lte as lte8 } from "drizzle-orm";
@@ -1462,6 +1471,8 @@ var researchRunQueries = sqliteTable("research_run_queries", {
1462
1471
  groundingSources: text("grounding_sources", { mode: "json" }).$type().notNull().default([]),
1463
1472
  citedDomains: text("cited_domains", { mode: "json" }).$type().notNull().default([]),
1464
1473
  searchQueries: text("search_queries", { mode: "json" }).$type().notNull().default([]),
1474
+ namedCompetitors: text("named_competitors", { mode: "json" }).$type().notNull().default([]),
1475
+ citedCompetitorDomains: text("cited_competitor_domains", { mode: "json" }).$type().notNull().default([]),
1465
1476
  answerMentioned: integer("answer_mentioned", { mode: "boolean" }),
1466
1477
  citationState: text("citation_state"),
1467
1478
  rawResponse: text("raw_response", { mode: "json" }).$type(),
@@ -4266,6 +4277,16 @@ var MIGRATION_VERSIONS = [
4266
4277
  updated_at TEXT NOT NULL
4267
4278
  )`
4268
4279
  ]
4280
+ },
4281
+ {
4282
+ // Keep research batches useful as a competitor-comparison workspace while
4283
+ // retaining their strict isolation from tracked visibility snapshots.
4284
+ version: 110,
4285
+ name: "research-query-named-and-cited-competitors",
4286
+ statements: [
4287
+ `ALTER TABLE research_run_queries ADD COLUMN named_competitors TEXT NOT NULL DEFAULT '[]'`,
4288
+ `ALTER TABLE research_run_queries ADD COLUMN cited_competitor_domains TEXT NOT NULL DEFAULT '[]'`
4289
+ ]
4269
4290
  }
4270
4291
  ];
4271
4292
  function rebuildBacklinkTableWithSource(tx, table) {
@@ -17392,6 +17413,7 @@ var SCHEMA_TABLE = {
17392
17413
  AdsDisconnectResponse: adsDisconnectResponseSchema,
17393
17414
  AdsGeoSearchResponse: adsGeoSearchResponseSchema,
17394
17415
  AdsInsightsResponse: adsInsightsResponseSchema,
17416
+ AdsLiveDeliveryDto: adsLiveDeliveryDtoSchema,
17395
17417
  AdsOperationReconcileResponse: adsOperationReconcileResponseSchema,
17396
17418
  AdsOperationResponse: adsOperationResponseSchema,
17397
17419
  AdsSummaryDto: adsSummaryDtoSchema,
@@ -17441,6 +17463,7 @@ var SCHEMA_TABLE = {
17441
17463
  ResearchRunDetailDto: researchRunDetailSchema,
17442
17464
  ResearchRunListDto: researchRunListSchema,
17443
17465
  DoctorReportDto: doctorReportSchema,
17466
+ GA4AiReferralDailyDto: ga4AiReferralDailyDtoSchema,
17444
17467
  GA4AiReferralHistoryEntry: ga4AiReferralHistoryEntrySchema,
17445
17468
  GA4MeasurementAnalysisDto: gaMeasurementAnalysisDtoSchema,
17446
17469
  GA4SessionHistoryEntry: ga4SessionHistoryEntrySchema,
@@ -20395,6 +20418,35 @@ var routeCatalog = [
20395
20418
  404: errorResponse("Project not found.")
20396
20419
  }
20397
20420
  },
20421
+ {
20422
+ method: "get",
20423
+ path: "/api/v1/projects/{name}/ads/live-delivery",
20424
+ summary: "Live provider read of ads status and metrics, with the local snapshot delta",
20425
+ description: "Calls the OpenAI Ads API right now with the stored connection and returns the provider's current status and metrics unaggregated, alongside the corresponding local snapshot values and an explicit per-entity delta. Dates on both sides are the ad account's local calendar dates. The first day of the window is measured from 00:00 in the account timezone on both sides, so it is a whole day either way and a difference on it is real; the current local day is in progress on both sides, so a difference there is snapshot staleness. Read-only: it never creates, updates, pauses, or activates anything, and it never waits for a sync run. The window counts calendar days in the account timezone, not fixed 24-hour periods, so a daylight-saving transition cannot move the first day. The walk is bounded by per-level caps and a budget of 40 reader calls (see bounds). Admission has two rules: a project may have only one live read running at a time (a second request is refused for as long as the first is actually walking, which can exceed any interval), and consecutive reads are spaced by at least one minute. The interval is counted as soon as the provider is called, so a read that fails upstream still holds it; only a read rejected before the provider was called leaves the next one free. A reader call is not one HTTP request: each list or insight read auto-paginates up to 100 pages, so the worst-case upstream cost of a single request is about 4000 HTTP requests, reported as bounds.maxUpstreamHttpRequests.",
20426
+ tags: ["ads"],
20427
+ parameters: [
20428
+ nameParameter,
20429
+ {
20430
+ name: "campaignId",
20431
+ in: "query",
20432
+ description: "Scope the walk to a single campaign.",
20433
+ schema: stringSchema
20434
+ },
20435
+ {
20436
+ name: "lookbackDays",
20437
+ in: "query",
20438
+ description: "Metrics window in calendar days of the ad account timezone (1-30, default 7).",
20439
+ schema: integerSchema
20440
+ }
20441
+ ],
20442
+ responses: {
20443
+ 200: jsonResponse("Live provider state and snapshot delta returned.", "AdsLiveDeliveryDto"),
20444
+ 400: errorResponse("Invalid query, or no ads connection for this project."),
20445
+ 404: errorResponse("Project not found."),
20446
+ 429: errorResponse("Another live read for this project is still running, or the minimum interval since the last provider read has not elapsed. error.details.reason says which."),
20447
+ 502: errorResponse("The OpenAI Ads API read failed.")
20448
+ }
20449
+ },
20398
20450
  {
20399
20451
  method: "post",
20400
20452
  path: "/api/v1/projects/{name}/bing/connect",
@@ -21155,7 +21207,8 @@ var routeCatalog = [
21155
21207
  {
21156
21208
  method: "get",
21157
21209
  path: "/api/v1/projects/{name}/ga/ai-referral-history",
21158
- summary: "Get AI referral sessions per day grouped by source and landing page",
21210
+ summary: "Get raw AI referral detail rows per day, landing page, and attribution dimension",
21211
+ description: "Detail rows, not totals. One row per landing page per attribution dimension, so a single day of one source is many rows and each is commonly worth one session. Use /ga/ai-referral-daily for per-date or per-source session counts.",
21159
21212
  tags: ["ga4"],
21160
21213
  parameters: [nameParameter, analyticsWindowParameter],
21161
21214
  responses: {
@@ -21164,6 +21217,19 @@ var routeCatalog = [
21164
21217
  404: errorResponse("Project not found.")
21165
21218
  }
21166
21219
  },
21220
+ {
21221
+ method: "get",
21222
+ path: "/api/v1/projects/{name}/ga/ai-referral-daily",
21223
+ summary: "Get AI referral sessions per day and per source",
21224
+ description: "Sums landing pages within one attribution dimension and never across dimensions, so totalSessions equals the aiSessionsDeduped reported by /ga/traffic for the same window. Sessions only: GA counts users as a distinct count at the grain requested, so an AI-referral user total cannot be summed from these rows and no un-dimensioned AI-referral fetch exists to supply one.",
21225
+ tags: ["ga4"],
21226
+ parameters: [nameParameter, analyticsWindowParameter],
21227
+ responses: {
21228
+ 200: jsonResponse("AI referral daily series returned.", "GA4AiReferralDailyDto"),
21229
+ 400: errorResponse("GA4 is not connected."),
21230
+ 404: errorResponse("Project not found.")
21231
+ }
21232
+ },
21167
21233
  {
21168
21234
  method: "get",
21169
21235
  path: "/api/v1/projects/{name}/ga/social-referral-history",
@@ -28465,6 +28531,116 @@ function registerAdsActivationRoutes(app, opts) {
28465
28531
  });
28466
28532
  }
28467
28533
 
28534
+ // ../api-routes/src/ads-live-delivery.ts
28535
+ function liveComparisonWindow(fetchedAtMs, lookbackDays, timezone) {
28536
+ const fetchedAtIso = new Date(fetchedAtMs).toISOString();
28537
+ return {
28538
+ startDate: isoDateDaysBeforeInTimeZone(fetchedAtIso, lookbackDays, timezone),
28539
+ endDate: formatIsoDateInTimeZone(fetchedAtIso, timezone)
28540
+ };
28541
+ }
28542
+ var EMPTY_METRIC_VALUES = {
28543
+ impressions: 0,
28544
+ clicks: 0,
28545
+ spendMicros: 0,
28546
+ conversions: 0
28547
+ };
28548
+ function presenceOf(live, stored) {
28549
+ if (live && stored) return AdsLivePresences.both;
28550
+ if (live) return AdsLivePresences["live-only"];
28551
+ return AdsLivePresences["stored-only"];
28552
+ }
28553
+ function fieldDelta(field, live, stored) {
28554
+ return live === stored ? null : { field, live, stored };
28555
+ }
28556
+ function buildFieldDeltas(live, stored) {
28557
+ if (!live || !stored) return [];
28558
+ const deltas = [
28559
+ fieldDelta("status", live.status, stored.status),
28560
+ fieldDelta("reviewStatus", live.reviewStatus, stored.reviewStatus),
28561
+ fieldDelta("name", live.name, stored.name)
28562
+ ];
28563
+ return deltas.filter((delta) => delta !== null);
28564
+ }
28565
+ function foldLiveRows(rows) {
28566
+ let values = EMPTY_METRIC_VALUES;
28567
+ for (const row of rows) {
28568
+ values = {
28569
+ impressions: values.impressions + Math.round(row.impressions ?? 0),
28570
+ clicks: values.clicks + Math.round(row.clicks ?? 0),
28571
+ spendMicros: values.spendMicros + dollarsToMicros(row.spend ?? 0),
28572
+ conversions: values.conversions + Math.round(row.conversions ?? 0)
28573
+ };
28574
+ }
28575
+ return values;
28576
+ }
28577
+ function sameMetrics(a, b) {
28578
+ return a.impressions === b.impressions && a.clicks === b.clicks && a.spendMicros === b.spendMicros && a.conversions === b.conversions;
28579
+ }
28580
+ function buildMetricDeltas(liveRows, storedRows, window) {
28581
+ if (liveRows === null && storedRows === null) return null;
28582
+ const liveByDate = /* @__PURE__ */ new Map();
28583
+ for (const row of liveRows ?? []) {
28584
+ if (row.date === null) continue;
28585
+ const bucket = liveByDate.get(row.date) ?? [];
28586
+ bucket.push(row);
28587
+ liveByDate.set(row.date, bucket);
28588
+ }
28589
+ const storedByDate = /* @__PURE__ */ new Map();
28590
+ for (const row of storedRows ?? []) {
28591
+ if (row.date < window.startDate || row.date > window.endDate) continue;
28592
+ storedByDate.set(row.date, row);
28593
+ }
28594
+ const dates = [.../* @__PURE__ */ new Set([...liveByDate.keys(), ...storedByDate.keys()])].sort();
28595
+ return dates.map((date) => {
28596
+ const liveBucket = liveByDate.get(date);
28597
+ const storedRow = storedByDate.get(date);
28598
+ const live = liveBucket ? foldLiveRows(liveBucket) : null;
28599
+ const stored = storedRow ? {
28600
+ impressions: storedRow.impressions,
28601
+ clicks: storedRow.clicks,
28602
+ spendMicros: storedRow.spendMicros,
28603
+ conversions: storedRow.conversions
28604
+ } : null;
28605
+ const drifted = live === null || stored === null || !sameMetrics(live, stored);
28606
+ return { date, live, stored, drifted };
28607
+ });
28608
+ }
28609
+ function buildLiveEntityComparison(observation, window) {
28610
+ const presence = presenceOf(observation.live, observation.stored);
28611
+ const fieldDeltas = buildFieldDeltas(observation.live, observation.stored);
28612
+ const metricDeltas = buildMetricDeltas(observation.liveMetrics, observation.storedMetrics, window);
28613
+ const drifted = presence !== AdsLivePresences.both || fieldDeltas.length > 0 || (metricDeltas?.some((delta) => delta.drifted) ?? false);
28614
+ return {
28615
+ entityType: observation.entityType,
28616
+ id: observation.id,
28617
+ parentId: observation.parentId,
28618
+ presence,
28619
+ live: observation.live,
28620
+ stored: observation.stored,
28621
+ fieldDeltas,
28622
+ liveMetrics: observation.liveMetrics,
28623
+ metricDeltas,
28624
+ drifted
28625
+ };
28626
+ }
28627
+ function summarizeLiveDrift(entities) {
28628
+ let driftedEntities = 0;
28629
+ let statusDrifted = 0;
28630
+ let metricsDrifted = 0;
28631
+ for (const entity of entities) {
28632
+ if (entity.drifted) driftedEntities += 1;
28633
+ if (entity.fieldDeltas.some((delta) => delta.field === "status")) statusDrifted += 1;
28634
+ if (entity.metricDeltas?.some((delta) => delta.drifted)) metricsDrifted += 1;
28635
+ }
28636
+ return {
28637
+ entitiesCompared: entities.length,
28638
+ driftedEntities,
28639
+ statusDrifted,
28640
+ metricsDrifted
28641
+ };
28642
+ }
28643
+
28468
28644
  // ../api-routes/src/ads.ts
28469
28645
  function runSourceDto(row) {
28470
28646
  const parsed = runStatusSchema.safeParse(row.status);
@@ -28599,6 +28775,12 @@ function storedActivityAssessment(snapshotStatus, rollupStatus, rollupImpression
28599
28775
  }
28600
28776
  }
28601
28777
  }
28778
+ var ADS_LIVE_MAX_CAMPAIGNS = 5;
28779
+ var ADS_LIVE_MAX_AD_GROUPS_PER_CAMPAIGN = 10;
28780
+ var ADS_LIVE_MAX_ADS_PER_AD_GROUP = 20;
28781
+ var ADS_LIVE_MAX_READER_CALLS = 40;
28782
+ var DEFAULT_ADS_LIVE_MAX_PAGES_PER_READER_CALL = 100;
28783
+ var DEFAULT_ADS_LIVE_DELIVERY_MIN_INTERVAL_MS = 6e4;
28602
28784
  var DEFAULT_RECONCILE_SWEEP_INTERVAL_MS = 6e4;
28603
28785
  var DEFAULT_RECONCILE_PENDING_STALE_MS = 5 * 6e4;
28604
28786
  var DEFAULT_RECONCILE_BACKOFF_BASE_MS = 5 * 6e4;
@@ -28961,6 +29143,47 @@ async function executeAdsRead(surface, read) {
28961
29143
  });
28962
29144
  }
28963
29145
  }
29146
+ function resolveAdsLiveDeliveryReader(opts, projectName) {
29147
+ const apiKey = opts.adsCredentialStore?.getConnection(projectName)?.apiKey;
29148
+ if (!apiKey) {
29149
+ throw validationError("No OpenAI Ads API key configured for this project");
29150
+ }
29151
+ if (!opts.adsLiveDeliveryReader) {
29152
+ throw validationError("Ads live delivery reads are not configured for this deployment");
29153
+ }
29154
+ return { apiKey, reader: opts.adsLiveDeliveryReader };
29155
+ }
29156
+ function upstreamStatusOf(error) {
29157
+ return typeof error === "object" && error !== null && "status" in error && typeof error.status === "number" && Number.isInteger(error.status) ? error.status : null;
29158
+ }
29159
+ function liveReadFailure(surface, entityId, error) {
29160
+ return { surface, entityId, upstreamStatus: upstreamStatusOf(error) };
29161
+ }
29162
+ function liveReadProviderError(surface, error) {
29163
+ const upstreamStatus = upstreamStatusOf(error);
29164
+ return providerError(
29165
+ `OpenAI Ads API ${surface} read failed`,
29166
+ upstreamStatus === null ? void 0 : { upstreamStatus }
29167
+ );
29168
+ }
29169
+ function liveEntityState(entity) {
29170
+ return {
29171
+ name: entity.name,
29172
+ status: entity.status,
29173
+ reviewStatus: entity.reviewStatus,
29174
+ mode: entity.mode,
29175
+ updatedAt: entity.updatedAt
29176
+ };
29177
+ }
29178
+ function storedEntityState(row) {
29179
+ return {
29180
+ name: row.name,
29181
+ status: row.status,
29182
+ reviewStatus: row.reviewStatus ?? null,
29183
+ upstreamUpdatedAt: row.upstreamUpdatedAt,
29184
+ syncedAt: row.syncedAt
29185
+ };
29186
+ }
28964
29187
  async function executeAdsOperation(app, request, input) {
28965
29188
  const hash = requestHash({ kind: input.kind, entityType: input.entityType, payload: input.payload });
28966
29189
  const existing = readOperationByKey(app, input.projectId, input.operationKey);
@@ -29599,6 +29822,21 @@ async function adsRoutes(app, opts) {
29599
29822
  opts.adsAccountVerificationCacheTtlMs ?? DEFAULT_ADS_ACCOUNT_VERIFICATION_CACHE_TTL_MS
29600
29823
  );
29601
29824
  const verificationCache = /* @__PURE__ */ new Map();
29825
+ const liveDeliveryMinIntervalMs = Math.max(
29826
+ 0,
29827
+ opts.adsLiveDeliveryMinIntervalMs ?? DEFAULT_ADS_LIVE_DELIVERY_MIN_INTERVAL_MS
29828
+ );
29829
+ const liveDeliveryMaxPagesPerReaderCall = Math.max(
29830
+ 1,
29831
+ opts.adsLiveDeliveryMaxPagesPerReaderCall ?? DEFAULT_ADS_LIVE_MAX_PAGES_PER_READER_CALL
29832
+ );
29833
+ const liveDeliveryInFlight = /* @__PURE__ */ new Set();
29834
+ const liveDeliveryLastAttemptAtMs = /* @__PURE__ */ new Map();
29835
+ const pruneLiveDeliveryAttempts = (nowMs) => {
29836
+ for (const [projectId, lastAtMs] of liveDeliveryLastAttemptAtMs) {
29837
+ if (nowMs - lastAtMs >= liveDeliveryMinIntervalMs) liveDeliveryLastAttemptAtMs.delete(projectId);
29838
+ }
29839
+ };
29602
29840
  const reconcilePolicy = {
29603
29841
  pendingMinIdleMs: pendingStaleMs,
29604
29842
  backoffBaseMs,
@@ -30378,6 +30616,264 @@ async function adsRoutes(app, opts) {
30378
30616
  };
30379
30617
  return response;
30380
30618
  });
30619
+ app.get("/projects/:name/ads/live-delivery", async (request) => {
30620
+ const project = resolveProject(app.db, request.params.name);
30621
+ const parsed = adsLiveDeliveryQuerySchema.safeParse(request.query);
30622
+ if (!parsed.success) {
30623
+ throw validationError("Invalid ads live delivery query", { issues: parsed.error.issues });
30624
+ }
30625
+ const { campaignId, lookbackDays } = parsed.data;
30626
+ const connection = app.db.select().from(adsConnections).where(eq28(adsConnections.projectId, project.id)).get();
30627
+ if (!connection) {
30628
+ throw validationError('No ads connection for this project. Run "canonry ads connect" first.');
30629
+ }
30630
+ const { apiKey, reader } = resolveAdsLiveDeliveryReader(opts, project.name);
30631
+ const verifyAdsAccount = opts.verifyAdsAccount;
30632
+ if (!verifyAdsAccount) {
30633
+ throw validationError("Ads account verification is not configured for this deployment");
30634
+ }
30635
+ const fetchedAtMs = Date.now();
30636
+ const lastAttemptAtMs = liveDeliveryLastAttemptAtMs.get(project.id);
30637
+ const remainingIntervalMs = liveDeliveryMinIntervalMs > 0 && lastAttemptAtMs !== void 0 ? lastAttemptAtMs + liveDeliveryMinIntervalMs - fetchedAtMs : 0;
30638
+ if (liveDeliveryInFlight.has(project.id)) {
30639
+ throw quotaExceeded("OpenAI Ads live delivery reads", {
30640
+ reason: "read-in-flight",
30641
+ minIntervalMs: liveDeliveryMinIntervalMs,
30642
+ // A LOWER bound, and only when the interval itself still blocks. How
30643
+ // much longer the running walk needs is unknowable from here, so once
30644
+ // it has outlived the interval there is no honest number to give and
30645
+ // the field is absent rather than guessed.
30646
+ ...remainingIntervalMs > 0 ? { retryAfterMs: remainingIntervalMs } : {}
30647
+ });
30648
+ }
30649
+ if (remainingIntervalMs > 0) {
30650
+ throw quotaExceeded("OpenAI Ads live delivery reads", {
30651
+ reason: "min-interval",
30652
+ minIntervalMs: liveDeliveryMinIntervalMs,
30653
+ retryAfterMs: remainingIntervalMs
30654
+ });
30655
+ }
30656
+ liveDeliveryInFlight.add(project.id);
30657
+ const spendLiveDeliveryAttempt = () => {
30658
+ if (liveDeliveryMinIntervalMs <= 0) return;
30659
+ pruneLiveDeliveryAttempts(fetchedAtMs);
30660
+ liveDeliveryLastAttemptAtMs.set(project.id, fetchedAtMs);
30661
+ };
30662
+ try {
30663
+ let readerCalls = 0;
30664
+ let truncated = false;
30665
+ const errors = [];
30666
+ const callProvider = async (surface, entityId, run) => {
30667
+ if (readerCalls >= ADS_LIVE_MAX_READER_CALLS) {
30668
+ truncated = true;
30669
+ return null;
30670
+ }
30671
+ readerCalls += 1;
30672
+ try {
30673
+ return await run();
30674
+ } catch (error) {
30675
+ errors.push(liveReadFailure(surface, entityId, error));
30676
+ return null;
30677
+ }
30678
+ };
30679
+ readerCalls += 1;
30680
+ let account;
30681
+ spendLiveDeliveryAttempt();
30682
+ try {
30683
+ account = await verifyAdsAccount(apiKey);
30684
+ } catch (error) {
30685
+ throw liveReadProviderError("account identity", error);
30686
+ }
30687
+ if (account.id !== connection.adAccountId) {
30688
+ throw validationError("The configured key belongs to a different OpenAI ad account; reconnect this project");
30689
+ }
30690
+ readerCalls += 1;
30691
+ let liveCampaigns;
30692
+ try {
30693
+ liveCampaigns = await reader.listCampaigns(apiKey);
30694
+ } catch (error) {
30695
+ throw liveReadProviderError("campaign list", error);
30696
+ }
30697
+ const scopedCampaigns = campaignId === void 0 ? liveCampaigns : liveCampaigns.filter((campaign) => campaign.id === campaignId);
30698
+ if (scopedCampaigns.length > ADS_LIVE_MAX_CAMPAIGNS) truncated = true;
30699
+ const walkedCampaigns = scopedCampaigns.slice(0, ADS_LIVE_MAX_CAMPAIGNS);
30700
+ const storedCampaignRows = app.db.select().from(adsCampaigns).where(and22(
30701
+ eq28(adsCampaigns.projectId, project.id),
30702
+ campaignId === void 0 ? void 0 : eq28(adsCampaigns.id, campaignId)
30703
+ )).all();
30704
+ const storedAdGroupRows = app.db.select().from(adsAdGroups).where(and22(
30705
+ eq28(adsAdGroups.projectId, project.id),
30706
+ campaignId === void 0 ? void 0 : eq28(adsAdGroups.campaignId, campaignId)
30707
+ )).all();
30708
+ const storedAdGroupIds = new Set(storedAdGroupRows.map((row) => row.id));
30709
+ const storedAdRows = app.db.select().from(adsAds).where(eq28(adsAds.projectId, project.id)).all().filter((row) => campaignId === void 0 || storedAdGroupIds.has(row.adGroupId));
30710
+ const storedInsightRows = app.db.select({
30711
+ level: adsInsightsDaily.level,
30712
+ entityId: adsInsightsDaily.entityId,
30713
+ date: adsInsightsDaily.date,
30714
+ impressions: adsInsightsDaily.impressions,
30715
+ clicks: adsInsightsDaily.clicks,
30716
+ spendMicros: adsInsightsDaily.spendMicros,
30717
+ conversions: adsInsightsDaily.conversions
30718
+ }).from(adsInsightsDaily).where(eq28(adsInsightsDaily.projectId, project.id)).all();
30719
+ const storedCampaignById = new Map(storedCampaignRows.map((row) => [row.id, row]));
30720
+ const storedAdGroupById = new Map(storedAdGroupRows.map((row) => [row.id, row]));
30721
+ const storedAdById = new Map(storedAdRows.map((row) => [row.id, row]));
30722
+ const storedMetricsByEntity = /* @__PURE__ */ new Map();
30723
+ for (const row of storedInsightRows) {
30724
+ const key = `${row.level}:${row.entityId}`;
30725
+ const bucket = storedMetricsByEntity.get(key) ?? [];
30726
+ bucket.push({
30727
+ date: row.date,
30728
+ impressions: row.impressions,
30729
+ clicks: row.clicks,
30730
+ spendMicros: row.spendMicros,
30731
+ conversions: row.conversions
30732
+ });
30733
+ storedMetricsByEntity.set(key, bucket);
30734
+ }
30735
+ const storedMetricsFor = (level, entityId) => storedMetricsByEntity.get(`${level}:${entityId}`) ?? [];
30736
+ const accountTimezone = account.timezone ?? "UTC";
30737
+ const window = liveComparisonWindow(fetchedAtMs, lookbackDays, accountTimezone);
30738
+ const insightsRequest = {
30739
+ startDate: window.startDate,
30740
+ fetchedAtMs,
30741
+ timezone: accountTimezone
30742
+ };
30743
+ const observations = [];
30744
+ const walkedCampaignIds = /* @__PURE__ */ new Set();
30745
+ const walkedAdGroupIds = /* @__PURE__ */ new Set();
30746
+ const walkedAdIds = /* @__PURE__ */ new Set();
30747
+ for (const campaign of walkedCampaigns) {
30748
+ walkedCampaignIds.add(campaign.id);
30749
+ const campaignMetrics = await callProvider(
30750
+ "campaign insights",
30751
+ campaign.id,
30752
+ () => reader.campaignInsights(apiKey, campaign.id, insightsRequest)
30753
+ );
30754
+ observations.push({
30755
+ entityType: AdsLiveEntityTypes.campaign,
30756
+ id: campaign.id,
30757
+ parentId: null,
30758
+ live: liveEntityState(campaign),
30759
+ stored: storedCampaignById.has(campaign.id) ? storedEntityState(storedCampaignById.get(campaign.id)) : null,
30760
+ // A metrics call that did not happen is not evidence about metrics, so
30761
+ // both sides stay null and no delta is claimed.
30762
+ liveMetrics: campaignMetrics,
30763
+ storedMetrics: campaignMetrics === null ? null : storedMetricsFor(AdsInsightLevels.campaign, campaign.id)
30764
+ });
30765
+ const liveAdGroups = await callProvider(
30766
+ "ad group list",
30767
+ campaign.id,
30768
+ () => reader.listAdGroups(apiKey, campaign.id)
30769
+ );
30770
+ if (liveAdGroups === null) continue;
30771
+ if (liveAdGroups.length > ADS_LIVE_MAX_AD_GROUPS_PER_CAMPAIGN) truncated = true;
30772
+ for (const adGroup of liveAdGroups.slice(0, ADS_LIVE_MAX_AD_GROUPS_PER_CAMPAIGN)) {
30773
+ walkedAdGroupIds.add(adGroup.id);
30774
+ const adGroupMetrics = await callProvider(
30775
+ "ad group insights",
30776
+ adGroup.id,
30777
+ () => reader.adGroupInsights(apiKey, adGroup.id, insightsRequest)
30778
+ );
30779
+ observations.push({
30780
+ entityType: AdsLiveEntityTypes.ad_group,
30781
+ id: adGroup.id,
30782
+ parentId: campaign.id,
30783
+ live: liveEntityState(adGroup),
30784
+ stored: storedAdGroupById.has(adGroup.id) ? storedEntityState(storedAdGroupById.get(adGroup.id)) : null,
30785
+ liveMetrics: adGroupMetrics,
30786
+ storedMetrics: adGroupMetrics === null ? null : storedMetricsFor(AdsInsightLevels.ad_group, adGroup.id)
30787
+ });
30788
+ const liveAds = await callProvider(
30789
+ "ad list",
30790
+ adGroup.id,
30791
+ () => reader.listAds(apiKey, adGroup.id)
30792
+ );
30793
+ if (liveAds === null) continue;
30794
+ if (liveAds.length > ADS_LIVE_MAX_ADS_PER_AD_GROUP) truncated = true;
30795
+ for (const ad of liveAds.slice(0, ADS_LIVE_MAX_ADS_PER_AD_GROUP)) {
30796
+ walkedAdIds.add(ad.id);
30797
+ observations.push({
30798
+ entityType: AdsLiveEntityTypes.ad,
30799
+ id: ad.id,
30800
+ parentId: adGroup.id,
30801
+ live: liveEntityState(ad),
30802
+ stored: storedAdById.has(ad.id) ? storedEntityState(storedAdById.get(ad.id)) : null,
30803
+ // The provider has no per-ad insights surface (same reason ads-sync
30804
+ // stores no ad-level rollups), so ads carry status evidence only.
30805
+ liveMetrics: null,
30806
+ storedMetrics: null
30807
+ });
30808
+ }
30809
+ }
30810
+ }
30811
+ if (!truncated && errors.length === 0) {
30812
+ for (const row of storedCampaignRows) {
30813
+ if (walkedCampaignIds.has(row.id)) continue;
30814
+ observations.push({
30815
+ entityType: AdsLiveEntityTypes.campaign,
30816
+ id: row.id,
30817
+ parentId: null,
30818
+ live: null,
30819
+ stored: storedEntityState(row),
30820
+ liveMetrics: null,
30821
+ storedMetrics: null
30822
+ });
30823
+ }
30824
+ for (const row of storedAdGroupRows) {
30825
+ if (walkedAdGroupIds.has(row.id)) continue;
30826
+ observations.push({
30827
+ entityType: AdsLiveEntityTypes.ad_group,
30828
+ id: row.id,
30829
+ parentId: row.campaignId,
30830
+ live: null,
30831
+ stored: storedEntityState(row),
30832
+ liveMetrics: null,
30833
+ storedMetrics: null
30834
+ });
30835
+ }
30836
+ for (const row of storedAdRows) {
30837
+ if (walkedAdIds.has(row.id)) continue;
30838
+ observations.push({
30839
+ entityType: AdsLiveEntityTypes.ad,
30840
+ id: row.id,
30841
+ parentId: row.adGroupId,
30842
+ live: null,
30843
+ stored: storedEntityState(row),
30844
+ liveMetrics: null,
30845
+ storedMetrics: null
30846
+ });
30847
+ }
30848
+ }
30849
+ const entities = observations.map(
30850
+ (observation) => buildLiveEntityComparison(observation, window)
30851
+ );
30852
+ const response = {
30853
+ basis: AdsLiveDeliveryBases.liveProviderRead,
30854
+ fetchedAt: new Date(fetchedAtMs).toISOString(),
30855
+ adAccountId: account.id,
30856
+ storedSnapshotSyncedAt: connection.lastSyncedAt ?? null,
30857
+ metricsWindow: { lookbackDays },
30858
+ bounds: {
30859
+ maxCampaigns: ADS_LIVE_MAX_CAMPAIGNS,
30860
+ maxAdGroupsPerCampaign: ADS_LIVE_MAX_AD_GROUPS_PER_CAMPAIGN,
30861
+ maxAdsPerAdGroup: ADS_LIVE_MAX_ADS_PER_AD_GROUP,
30862
+ maxReaderCalls: ADS_LIVE_MAX_READER_CALLS,
30863
+ readerCalls,
30864
+ maxPagesPerReaderCall: liveDeliveryMaxPagesPerReaderCall,
30865
+ maxUpstreamHttpRequests: ADS_LIVE_MAX_READER_CALLS * liveDeliveryMaxPagesPerReaderCall,
30866
+ truncated
30867
+ },
30868
+ entities,
30869
+ drift: summarizeLiveDrift(entities),
30870
+ errors
30871
+ };
30872
+ return response;
30873
+ } finally {
30874
+ liveDeliveryInFlight.delete(project.id);
30875
+ }
30876
+ });
30381
30877
  app.get("/projects/:name/ads/summary", async (request) => {
30382
30878
  const project = resolveProject(app.db, request.params.name);
30383
30879
  const row = app.db.select().from(adsConnections).where(eq28(adsConnections.projectId, project.id)).get();
@@ -31322,6 +31818,160 @@ function buildSessionHistory(landingPageDays, dailyTotals) {
31322
31818
  });
31323
31819
  }
31324
31820
 
31821
+ // ../api-routes/src/ga-ai-referral-aggregation.ts
31822
+ function normalizeAiTrafficClass(value) {
31823
+ return value === AiReferralTrafficClasses.paid ? AiReferralTrafficClasses.paid : AiReferralTrafficClasses.organic;
31824
+ }
31825
+ function emptyAiCounts() {
31826
+ return { sessions: 0, users: 0 };
31827
+ }
31828
+ function resolveWinningDimensions(rows) {
31829
+ const groups = /* @__PURE__ */ new Map();
31830
+ for (const row of rows) {
31831
+ const key = `${row.date}\0${row.source}\0${row.medium}`;
31832
+ let group = groups.get(key);
31833
+ if (!group) {
31834
+ group = { date: row.date, source: row.source, medium: row.medium, byDimension: /* @__PURE__ */ new Map() };
31835
+ groups.set(key, group);
31836
+ }
31837
+ let dim = group.byDimension.get(row.sourceDimension);
31838
+ if (!dim) {
31839
+ dim = { paidSessions: 0, organicSessions: 0 };
31840
+ group.byDimension.set(row.sourceDimension, dim);
31841
+ }
31842
+ const sessions = row.sessions ?? 0;
31843
+ if (normalizeAiTrafficClass(row.trafficClass) === AiReferralTrafficClasses.paid) {
31844
+ dim.paidSessions += sessions;
31845
+ } else {
31846
+ dim.organicSessions += sessions;
31847
+ }
31848
+ }
31849
+ const winners = [];
31850
+ for (const group of groups.values()) {
31851
+ const dims = [...group.byDimension.values()];
31852
+ const bestSessions = dims.reduce((best, d) => d.paidSessions + d.organicSessions > best.paidSessions + best.organicSessions ? d : best);
31853
+ winners.push({
31854
+ date: group.date,
31855
+ source: group.source,
31856
+ medium: group.medium,
31857
+ paidSessions: bestSessions.paidSessions,
31858
+ organicSessions: bestSessions.organicSessions
31859
+ });
31860
+ }
31861
+ return winners;
31862
+ }
31863
+ function resolveLegacyWinningUsers(rows) {
31864
+ const groups = /* @__PURE__ */ new Map();
31865
+ for (const row of rows) {
31866
+ const key = `${row.date}\0${row.source}\0${row.medium}`;
31867
+ let byDimension = groups.get(key);
31868
+ if (!byDimension) {
31869
+ byDimension = /* @__PURE__ */ new Map();
31870
+ groups.set(key, byDimension);
31871
+ }
31872
+ let dim = byDimension.get(row.sourceDimension);
31873
+ if (!dim) {
31874
+ dim = { paidUsers: 0, organicUsers: 0 };
31875
+ byDimension.set(row.sourceDimension, dim);
31876
+ }
31877
+ const users = row.users ?? 0;
31878
+ if (normalizeAiTrafficClass(row.trafficClass) === AiReferralTrafficClasses.paid) {
31879
+ dim.paidUsers += users;
31880
+ } else {
31881
+ dim.organicUsers += users;
31882
+ }
31883
+ }
31884
+ let paidUsers = 0;
31885
+ let organicUsers = 0;
31886
+ for (const byDimension of groups.values()) {
31887
+ const dims = [...byDimension.values()];
31888
+ const best = dims.reduce((winner, d) => d.paidUsers + d.organicUsers > winner.paidUsers + winner.organicUsers ? d : winner);
31889
+ paidUsers += best.paidUsers;
31890
+ organicUsers += best.organicUsers;
31891
+ }
31892
+ return { paidUsers, organicUsers };
31893
+ }
31894
+ function summarizeAiReferralCounts(rows) {
31895
+ const paidDeduped = emptyAiCounts();
31896
+ const organicDeduped = emptyAiCounts();
31897
+ const paidBySession = emptyAiCounts();
31898
+ const organicBySession = emptyAiCounts();
31899
+ const bySessionChannelGroup = /* @__PURE__ */ new Map();
31900
+ for (const winner of resolveWinningDimensions(rows)) {
31901
+ paidDeduped.sessions += winner.paidSessions;
31902
+ organicDeduped.sessions += winner.organicSessions;
31903
+ }
31904
+ const legacyUsers = resolveLegacyWinningUsers(rows);
31905
+ paidDeduped.users = legacyUsers.paidUsers;
31906
+ organicDeduped.users = legacyUsers.organicUsers;
31907
+ for (const row of rows) {
31908
+ if (row.sourceDimension !== "session") continue;
31909
+ const sessions = row.sessions ?? 0;
31910
+ const users = row.users ?? 0;
31911
+ bySessionChannelGroup.set(
31912
+ row.channelGroup,
31913
+ (bySessionChannelGroup.get(row.channelGroup) ?? 0) + sessions
31914
+ );
31915
+ const bucket = normalizeAiTrafficClass(row.trafficClass) === AiReferralTrafficClasses.paid ? paidBySession : organicBySession;
31916
+ bucket.sessions += sessions;
31917
+ bucket.users += users;
31918
+ }
31919
+ return {
31920
+ paidDeduped,
31921
+ organicDeduped,
31922
+ paidBySession,
31923
+ organicBySession,
31924
+ bySessionChannelGroup,
31925
+ deduped: {
31926
+ sessions: paidDeduped.sessions + organicDeduped.sessions,
31927
+ users: paidDeduped.users + organicDeduped.users
31928
+ },
31929
+ bySession: {
31930
+ sessions: paidBySession.sessions + organicBySession.sessions,
31931
+ users: paidBySession.users + organicBySession.users
31932
+ }
31933
+ };
31934
+ }
31935
+ function buildAiReferralDailySeries(rows) {
31936
+ const byDate = /* @__PURE__ */ new Map();
31937
+ const sourceTotals = /* @__PURE__ */ new Map();
31938
+ let totalSessions = 0;
31939
+ let totalPaidSessions = 0;
31940
+ let totalOrganicSessions = 0;
31941
+ for (const winner of resolveWinningDimensions(rows)) {
31942
+ const sessions = winner.paidSessions + winner.organicSessions;
31943
+ let bySource = byDate.get(winner.date);
31944
+ if (!bySource) {
31945
+ bySource = /* @__PURE__ */ new Map();
31946
+ byDate.set(winner.date, bySource);
31947
+ }
31948
+ let bucket = bySource.get(winner.source);
31949
+ if (!bucket) {
31950
+ bucket = { source: winner.source, sessions: 0, paidSessions: 0, organicSessions: 0 };
31951
+ bySource.set(winner.source, bucket);
31952
+ }
31953
+ bucket.sessions += sessions;
31954
+ bucket.paidSessions += winner.paidSessions;
31955
+ bucket.organicSessions += winner.organicSessions;
31956
+ sourceTotals.set(winner.source, (sourceTotals.get(winner.source) ?? 0) + sessions);
31957
+ totalSessions += sessions;
31958
+ totalPaidSessions += winner.paidSessions;
31959
+ totalOrganicSessions += winner.organicSessions;
31960
+ }
31961
+ const days = [...byDate.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([date, bySource]) => {
31962
+ const sources2 = [...bySource.values()].sort((a, b) => b.sessions - a.sessions || a.source.localeCompare(b.source));
31963
+ return {
31964
+ date,
31965
+ sessions: sources2.reduce((sum, s) => sum + s.sessions, 0),
31966
+ paidSessions: sources2.reduce((sum, s) => sum + s.paidSessions, 0),
31967
+ organicSessions: sources2.reduce((sum, s) => sum + s.organicSessions, 0),
31968
+ bySource: sources2
31969
+ };
31970
+ });
31971
+ const sources = [...sourceTotals.entries()].sort(([aSource, aSessions], [bSource, bSessions]) => bSessions - aSessions || aSource.localeCompare(bSource)).map(([source]) => source);
31972
+ return { days, sources, totalSessions, totalPaidSessions, totalOrganicSessions };
31973
+ }
31974
+
31325
31975
  // ../api-routes/src/ga.ts
31326
31976
  function gaLog(level, action, ctx) {
31327
31977
  const entry = { ts: (/* @__PURE__ */ new Date()).toISOString(), level, module: "GA4Routes", action, ...ctx };
@@ -31378,12 +32028,6 @@ function pickWinningDimension(rows, tupleKey) {
31378
32028
  }
31379
32029
  return [...winners.values()].sort((a, b) => (b.sessions ?? 0) - (a.sessions ?? 0));
31380
32030
  }
31381
- function normalizeAiTrafficClass(value) {
31382
- return value === AiReferralTrafficClasses.paid ? AiReferralTrafficClasses.paid : AiReferralTrafficClasses.organic;
31383
- }
31384
- function emptyAiCounts() {
31385
- return { sessions: 0, users: 0 };
31386
- }
31387
32031
  function persistAcquisitionMeasurement(db, input) {
31388
32032
  const { startDate, endDate } = input.report;
31389
32033
  db.transaction((tx) => {
@@ -31512,70 +32156,6 @@ function persistMeasurementError(db, projectId, component, message, updatedAt) {
31512
32156
  }
31513
32157
  }).run();
31514
32158
  }
31515
- function summarizeAiReferralCounts(rows) {
31516
- const dedupeGroups = /* @__PURE__ */ new Map();
31517
- const bySessionChannelGroup = /* @__PURE__ */ new Map();
31518
- const paidDeduped = emptyAiCounts();
31519
- const organicDeduped = emptyAiCounts();
31520
- const paidBySession = emptyAiCounts();
31521
- const organicBySession = emptyAiCounts();
31522
- for (const row of rows) {
31523
- const isPaid = normalizeAiTrafficClass(row.trafficClass) === AiReferralTrafficClasses.paid;
31524
- const sessions = row.sessions ?? 0;
31525
- const users = row.users ?? 0;
31526
- const key = `${row.date}\0${row.source}\0${row.medium}`;
31527
- let byDimension = dedupeGroups.get(key);
31528
- if (!byDimension) {
31529
- byDimension = /* @__PURE__ */ new Map();
31530
- dedupeGroups.set(key, byDimension);
31531
- }
31532
- let dim = byDimension.get(row.sourceDimension);
31533
- if (!dim) {
31534
- dim = { paidSessions: 0, organicSessions: 0, paidUsers: 0, organicUsers: 0 };
31535
- byDimension.set(row.sourceDimension, dim);
31536
- }
31537
- if (isPaid) {
31538
- dim.paidSessions += sessions;
31539
- dim.paidUsers += users;
31540
- } else {
31541
- dim.organicSessions += sessions;
31542
- dim.organicUsers += users;
31543
- }
31544
- if (row.sourceDimension === "session") {
31545
- bySessionChannelGroup.set(
31546
- row.channelGroup,
31547
- (bySessionChannelGroup.get(row.channelGroup) ?? 0) + sessions
31548
- );
31549
- const sessionBucket = isPaid ? paidBySession : organicBySession;
31550
- sessionBucket.sessions += sessions;
31551
- sessionBucket.users += users;
31552
- }
31553
- }
31554
- for (const byDimension of dedupeGroups.values()) {
31555
- const dims = [...byDimension.values()];
31556
- const bestSessions = dims.reduce((best, d) => d.paidSessions + d.organicSessions > best.paidSessions + best.organicSessions ? d : best);
31557
- const bestUsers = dims.reduce((best, d) => d.paidUsers + d.organicUsers > best.paidUsers + best.organicUsers ? d : best);
31558
- paidDeduped.sessions += bestSessions.paidSessions;
31559
- organicDeduped.sessions += bestSessions.organicSessions;
31560
- paidDeduped.users += bestUsers.paidUsers;
31561
- organicDeduped.users += bestUsers.organicUsers;
31562
- }
31563
- return {
31564
- paidDeduped,
31565
- organicDeduped,
31566
- paidBySession,
31567
- organicBySession,
31568
- bySessionChannelGroup,
31569
- deduped: {
31570
- sessions: paidDeduped.sessions + organicDeduped.sessions,
31571
- users: paidDeduped.users + organicDeduped.users
31572
- },
31573
- bySession: {
31574
- sessions: paidBySession.sessions + organicBySession.sessions,
31575
- users: paidBySession.users + organicBySession.users
31576
- }
31577
- };
31578
- }
31579
32159
  async function refreshOAuthTokenIfNeeded(googleStore, authConfig, canonicalDomain, oauthConn) {
31580
32160
  const expiresAt = oauthConn.tokenExpiresAt ? new Date(oauthConn.tokenExpiresAt).getTime() : 0;
31581
32161
  const fiveMinutes = 5 * 60 * 1e3;
@@ -32277,6 +32857,23 @@ async function ga4Routes(app, opts) {
32277
32857
  ).orderBy(gaAiReferrals.date).all();
32278
32858
  return rows;
32279
32859
  });
32860
+ app.get("/projects/:name/ga/ai-referral-daily", async (request, _reply) => {
32861
+ const project = resolveProject(app.db, request.params.name);
32862
+ requireGa4Connection(opts, project.name, project.canonicalDomain);
32863
+ const cutoffDate = windowCutoff(parseWindow(request.query.window))?.slice(0, 10) ?? null;
32864
+ const conditions = [eq31(gaAiReferrals.projectId, project.id)];
32865
+ if (cutoffDate) conditions.push(sql15`${gaAiReferrals.date} >= ${cutoffDate}`);
32866
+ const rows = app.db.select({
32867
+ date: gaAiReferrals.date,
32868
+ source: gaAiReferrals.source,
32869
+ medium: gaAiReferrals.medium,
32870
+ trafficClass: gaAiReferrals.trafficClass,
32871
+ sourceDimension: gaAiReferrals.sourceDimension,
32872
+ channelGroup: gaAiReferrals.channelGroup,
32873
+ sessions: gaAiReferrals.sessions
32874
+ }).from(gaAiReferrals).where(and25(...conditions)).all();
32875
+ return buildAiReferralDailySeries(rows);
32876
+ });
32280
32877
  app.get("/projects/:name/ga/social-referral-history", async (request, _reply) => {
32281
32878
  const project = resolveProject(app.db, request.params.name);
32282
32879
  requireGa4Connection(opts, project.name, project.canonicalDomain);
@@ -43406,7 +44003,7 @@ function serializeRun(row) {
43406
44003
  return { id: row.id, projectId: row.projectId, status: row.status, provider: row.provider, requestedModel: row.requestedModel, resolvedModel: row.resolvedModel, location: row.location ?? null, totalQueries: row.totalQueries, completedQueries: row.completedQueries, failedQueries: row.failedQueries, error: row.error, startedAt: row.startedAt, finishedAt: row.finishedAt, createdAt: row.createdAt };
43407
44004
  }
43408
44005
  function serializeQuery(row) {
43409
- return { id: row.id, position: row.position, query: row.queryText, status: row.status, requestedModel: row.requestedModel, resolvedModel: row.resolvedModel, servedModel: row.servedModel, answerText: row.answerText, groundingSources: row.groundingSources, citedDomains: row.citedDomains, searchQueries: row.searchQueries, answerMentioned: row.answerMentioned, citationState: row.citationState, error: row.error, startedAt: row.startedAt, finishedAt: row.finishedAt, createdAt: row.createdAt };
44006
+ return { id: row.id, position: row.position, query: row.queryText, status: row.status, requestedModel: row.requestedModel, resolvedModel: row.resolvedModel, servedModel: row.servedModel, answerText: row.answerText, groundingSources: row.groundingSources, citedDomains: row.citedDomains, searchQueries: row.searchQueries, namedCompetitors: row.namedCompetitors, citedCompetitorDomains: row.citedCompetitorDomains, answerMentioned: row.answerMentioned, citationState: row.citationState, error: row.error, startedAt: row.startedAt, finishedAt: row.finishedAt, createdAt: row.createdAt };
43410
44007
  }
43411
44008
 
43412
44009
  // ../api-routes/src/index.ts
@@ -43527,6 +44124,9 @@ async function apiRoutes(app, opts) {
43527
44124
  adsCredentialStore: opts.adsCredentialStore,
43528
44125
  verifyAdsAccount: opts.verifyAdsAccount,
43529
44126
  adsReader: opts.adsReader,
44127
+ adsLiveDeliveryReader: opts.adsLiveDeliveryReader,
44128
+ adsLiveDeliveryMinIntervalMs: opts.adsLiveDeliveryMinIntervalMs,
44129
+ adsLiveDeliveryMaxPagesPerReaderCall: opts.adsLiveDeliveryMaxPagesPerReaderCall,
43530
44130
  onAdsSyncRequested: opts.onAdsSyncRequested,
43531
44131
  adsOperator: opts.adsOperator,
43532
44132
  adsReconcileSweepIntervalMs: opts.adsReconcileSweepIntervalMs,
@@ -43870,6 +44470,15 @@ function computeCompetitorOverlap(normalized, competitorDomains) {
43870
44470
  }
43871
44471
  return [...overlapSet];
43872
44472
  }
44473
+ function computeCitedCompetitorDomains(citedDomains, competitorDomains) {
44474
+ const citedCompetitors = /* @__PURE__ */ new Set();
44475
+ for (const citedDomain of citedDomains) {
44476
+ for (const competitorDomain of competitorDomains) {
44477
+ if (domainMatches(citedDomain, competitorDomain)) citedCompetitors.add(competitorDomain);
44478
+ }
44479
+ }
44480
+ return [...citedCompetitors];
44481
+ }
43873
44482
  function escapeRegExp5(value) {
43874
44483
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
43875
44484
  }
@@ -43930,7 +44539,7 @@ function extractRecommendedCompetitors(answerText, ownDomains, citedDomains, com
43930
44539
  for (const pattern of candidatePatterns) {
43931
44540
  let match;
43932
44541
  while ((match = pattern.exec(answerText)) !== null) {
43933
- const candidate = cleanCandidateName(match[1] ?? "");
44542
+ const candidate = cleanCandidateName(match[1]);
43934
44543
  const candidateKey = brandKeyFromText(candidate);
43935
44544
  if (!candidateKey) continue;
43936
44545
  if (genericKeys.has(candidateKey)) continue;
@@ -44645,6 +45254,7 @@ export {
44645
45254
  classifyAiUserFetch,
44646
45255
  determineCitationState,
44647
45256
  computeCompetitorOverlap,
45257
+ computeCitedCompetitorDomains,
44648
45258
  extractRecommendedCompetitors,
44649
45259
  getPlaceDetails,
44650
45260
  hashPlaceDetails,