@ainyc/canonry 1.41.0 → 1.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,10 +2,12 @@ import {
2
2
  IntelligenceService,
3
3
  apiKeys,
4
4
  auditLog,
5
+ bingCoverageSnapshots,
5
6
  bingUrlInspections,
6
7
  competitors,
7
8
  createLogger,
8
9
  gaAiReferrals,
10
+ gaSocialReferrals,
9
11
  gaTrafficSnapshots,
10
12
  gaTrafficSummaries,
11
13
  gscCoverageSnapshots,
@@ -21,7 +23,7 @@ import {
21
23
  runs,
22
24
  schedules,
23
25
  usageCounters
24
- } from "./chunk-EUBC5EGC.js";
26
+ } from "./chunk-SVPQUYTG.js";
25
27
 
26
28
  // src/config.ts
27
29
  import fs from "fs";
@@ -650,6 +652,12 @@ var bingKeywordStatsDtoSchema = z6.object({
650
652
  ctr: z6.number(),
651
653
  averagePosition: z6.number()
652
654
  });
655
+ var bingCoverageSnapshotDtoSchema = z6.object({
656
+ date: z6.string(),
657
+ indexed: z6.number(),
658
+ notIndexed: z6.number(),
659
+ unknown: z6.number()
660
+ });
653
661
  var bingSubmitResultDtoSchema = z6.object({
654
662
  url: z6.string(),
655
663
  status: z6.enum(["success", "error"]),
@@ -1101,6 +1109,14 @@ var ga4AiReferralDtoSchema = z12.object({
1101
1109
  users: z12.number(),
1102
1110
  sourceDimension: ga4SourceDimensionSchema
1103
1111
  });
1112
+ var ga4SocialReferralDtoSchema = z12.object({
1113
+ source: z12.string(),
1114
+ medium: z12.string(),
1115
+ sessions: z12.number(),
1116
+ users: z12.number(),
1117
+ /** GA4 default channel group (e.g. 'Organic Social', 'Paid Social') */
1118
+ channelGroup: z12.string()
1119
+ });
1104
1120
  var ga4TrafficSummaryDtoSchema = z12.object({
1105
1121
  totalSessions: z12.number(),
1106
1122
  totalOrganicSessions: z12.number(),
@@ -1116,6 +1132,17 @@ var ga4TrafficSummaryDtoSchema = z12.object({
1116
1132
  aiSessionsDeduped: z12.number(),
1117
1133
  /** Deduped AI user total: MAX(users) per date+source+medium across attribution dimensions, then summed. */
1118
1134
  aiUsersDeduped: z12.number(),
1135
+ socialReferrals: z12.array(ga4SocialReferralDtoSchema),
1136
+ /** Total social sessions (session-scoped, no cross-dimension dedup needed). */
1137
+ socialSessions: z12.number(),
1138
+ /** Total social users (session-scoped, no cross-dimension dedup needed). */
1139
+ socialUsers: z12.number(),
1140
+ /** Organic sessions as a percentage of total sessions (0–100, rounded). */
1141
+ organicSharePct: z12.number(),
1142
+ /** Deduped AI sessions as a percentage of total sessions (0–100, rounded). */
1143
+ aiSharePct: z12.number(),
1144
+ /** Social sessions as a percentage of total sessions (0–100, rounded). */
1145
+ socialSharePct: z12.number(),
1119
1146
  lastSyncedAt: z12.string().nullable()
1120
1147
  });
1121
1148
  var ga4AiReferralHistoryEntrySchema = z12.object({
@@ -1127,6 +1154,15 @@ var ga4AiReferralHistoryEntrySchema = z12.object({
1127
1154
  /** Which GA4 dimension this row came from: session (sessionSource), first_user (firstUserSource), or manual_utm (utm_source parameter) */
1128
1155
  sourceDimension: ga4SourceDimensionSchema
1129
1156
  });
1157
+ var ga4SocialReferralHistoryEntrySchema = z12.object({
1158
+ date: z12.string(),
1159
+ source: z12.string(),
1160
+ medium: z12.string(),
1161
+ sessions: z12.number(),
1162
+ users: z12.number(),
1163
+ /** GA4 default channel group (e.g. 'Organic Social', 'Paid Social') */
1164
+ channelGroup: z12.string()
1165
+ });
1130
1166
  var ga4SessionHistoryEntrySchema = z12.object({
1131
1167
  date: z12.string(),
1132
1168
  sessions: z12.number(),
@@ -4712,6 +4748,18 @@ var routeCatalog = [
4712
4748
  404: { description: "Project not found." }
4713
4749
  }
4714
4750
  },
4751
+ {
4752
+ method: "get",
4753
+ path: "/api/v1/projects/{name}/bing/coverage/history",
4754
+ summary: "Get Bing coverage history snapshots",
4755
+ tags: ["bing"],
4756
+ parameters: [nameParameter, limitQueryParameter],
4757
+ responses: {
4758
+ 200: { description: "Bing coverage history returned." },
4759
+ 400: { description: "Bing is not configured for this project." },
4760
+ 404: { description: "Project not found." }
4761
+ }
4762
+ },
4715
4763
  {
4716
4764
  method: "get",
4717
4765
  path: "/api/v1/projects/{name}/bing/inspections",
@@ -5296,6 +5344,42 @@ var routeCatalog = [
5296
5344
  404: { description: "Project not found." }
5297
5345
  }
5298
5346
  },
5347
+ {
5348
+ method: "get",
5349
+ path: "/api/v1/projects/{name}/ga/social-referral-history",
5350
+ summary: "Get social media referral sessions per day grouped by source",
5351
+ tags: ["ga4"],
5352
+ parameters: [nameParameter],
5353
+ responses: {
5354
+ 200: { description: "Social referral history returned." },
5355
+ 400: { description: "GA4 is not connected." },
5356
+ 404: { description: "Project not found." }
5357
+ }
5358
+ },
5359
+ {
5360
+ method: "get",
5361
+ path: "/api/v1/projects/{name}/ga/social-referral-trend",
5362
+ summary: "Get social referral trend (7d/30d) with biggest mover",
5363
+ tags: ["ga4"],
5364
+ parameters: [nameParameter],
5365
+ responses: {
5366
+ 200: { description: "Social referral trend returned." },
5367
+ 400: { description: "GA4 is not connected." },
5368
+ 404: { description: "Project not found." }
5369
+ }
5370
+ },
5371
+ {
5372
+ method: "get",
5373
+ path: "/api/v1/projects/{name}/ga/attribution-trend",
5374
+ summary: "Get per-channel attribution trends (7d/30d) for organic, AI, and social",
5375
+ tags: ["ga4"],
5376
+ parameters: [nameParameter],
5377
+ responses: {
5378
+ 200: { description: "Attribution trend returned." },
5379
+ 400: { description: "GA4 is not connected." },
5380
+ 404: { description: "Project not found." }
5381
+ }
5382
+ },
5299
5383
  {
5300
5384
  method: "get",
5301
5385
  path: "/api/v1/projects/{name}/ga/session-history",
@@ -5891,6 +5975,7 @@ var GSC_DATA_LAG_DAYS = 3;
5891
5975
  var INDEXING_API_BASE = "https://indexing.googleapis.com/v3";
5892
5976
  var INDEXING_API_DAILY_LIMIT = 200;
5893
5977
  var GOOGLE_REQUEST_TIMEOUT_MS = 3e4;
5978
+ var GSC_MAX_PAGES = 40;
5894
5979
 
5895
5980
  // ../integration-google/src/types.ts
5896
5981
  var GoogleAuthError = class extends Error {
@@ -5989,7 +6074,15 @@ async function exchangeCode(clientId, clientSecret, code, redirectUri) {
5989
6074
  });
5990
6075
  if (!res.ok) {
5991
6076
  const body = await res.text();
5992
- throw new GoogleAuthError(`Token exchange failed (${res.status}): ${body}`);
6077
+ let detail = "";
6078
+ try {
6079
+ const parsed = JSON.parse(body);
6080
+ if (parsed.error) detail = parsed.error;
6081
+ if (parsed.error_description) detail += detail ? `: ${parsed.error_description}` : parsed.error_description;
6082
+ } catch {
6083
+ detail = body.length <= 120 ? body : `${body.slice(0, 120)}...`;
6084
+ }
6085
+ throw new GoogleAuthError(`Token exchange failed (${res.status}): ${detail}`);
5993
6086
  }
5994
6087
  return await res.json();
5995
6088
  }
@@ -6010,7 +6103,15 @@ async function refreshAccessToken(clientId, clientSecret, currentRefreshToken) {
6010
6103
  });
6011
6104
  if (!res.ok) {
6012
6105
  const body = await res.text();
6013
- throw new GoogleAuthError(`Token refresh failed (${res.status}): ${body}`);
6106
+ let detail = "";
6107
+ try {
6108
+ const parsed = JSON.parse(body);
6109
+ if (parsed.error) detail = parsed.error;
6110
+ if (parsed.error_description) detail += detail ? `: ${parsed.error_description}` : parsed.error_description;
6111
+ } catch {
6112
+ detail = body.length <= 120 ? body : `${body.slice(0, 120)}...`;
6113
+ }
6114
+ throw new GoogleAuthError(`Token refresh failed (${res.status}): ${detail}`);
6014
6115
  }
6015
6116
  return await res.json();
6016
6117
  }
@@ -6057,6 +6158,18 @@ function validateUrl(urlParam) {
6057
6158
  throw new GoogleApiError("URL must be a valid URL", 400);
6058
6159
  }
6059
6160
  }
6161
+ function validateDate(date, label) {
6162
+ if (!date || typeof date !== "string" || date.trim().length === 0) {
6163
+ throw new GoogleApiError(`${label} is required and must be a non-empty string`, 400);
6164
+ }
6165
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
6166
+ throw new GoogleApiError(`${label} must be in YYYY-MM-DD format, got "${date}"`, 400);
6167
+ }
6168
+ const parsed = /* @__PURE__ */ new Date(`${date}T00:00:00Z`);
6169
+ if (Number.isNaN(parsed.getTime())) {
6170
+ throw new GoogleApiError(`${label} is not a valid date, got "${date}"`, 400);
6171
+ }
6172
+ }
6060
6173
  function gscClientLog(level, action, ctx) {
6061
6174
  const entry = { ts: (/* @__PURE__ */ new Date()).toISOString(), level, module: "GscClient", action, ...ctx };
6062
6175
  const stream = level === "error" ? process.stderr : process.stdout;
@@ -6085,7 +6198,8 @@ async function gscFetch(accessToken, url, opts) {
6085
6198
  if (!res.ok) {
6086
6199
  const body = await res.text();
6087
6200
  gscClientLog("error", "http.error", { url, method, httpStatus: res.status });
6088
- throw new GoogleApiError(`GSC API error (${res.status}): ${body}`, res.status);
6201
+ const detail = body.length <= 500 ? body : `${body.slice(0, 500)}... [truncated]`;
6202
+ throw new GoogleApiError(`GSC API error (${res.status}): ${detail}`, res.status);
6089
6203
  }
6090
6204
  return await res.json();
6091
6205
  }
@@ -6110,10 +6224,16 @@ async function listSitemaps(accessToken, siteUrl) {
6110
6224
  async function fetchSearchAnalytics(accessToken, siteUrl, opts) {
6111
6225
  validateAccessToken(accessToken);
6112
6226
  validateSiteUrl(siteUrl);
6227
+ validateDate(opts.startDate, "startDate");
6228
+ validateDate(opts.endDate, "endDate");
6113
6229
  const allRows = [];
6114
6230
  let startRow = 0;
6115
6231
  const dimensions = opts.dimensions ?? ["query", "page", "country", "device", "date"];
6116
6232
  for (; ; ) {
6233
+ if (startRow >= GSC_MAX_ROWS_PER_REQUEST * GSC_MAX_PAGES) {
6234
+ gscClientLog("error", "pagination.safety-limit", { siteUrl, startRow, maxRows: GSC_MAX_ROWS_PER_REQUEST * GSC_MAX_PAGES });
6235
+ break;
6236
+ }
6117
6237
  const requestBody = {
6118
6238
  startDate: opts.startDate,
6119
6239
  endDate: opts.endDate,
@@ -6183,6 +6303,7 @@ var GOOGLE_TOKEN_URL2 = "https://oauth2.googleapis.com/token";
6183
6303
  var GA4_DEFAULT_SYNC_DAYS = 30;
6184
6304
  var GA4_MAX_SYNC_DAYS = 90;
6185
6305
  var GA4_REQUEST_TIMEOUT_MS = 3e4;
6306
+ var GA4_MAX_PAGES = 50;
6186
6307
 
6187
6308
  // ../integration-google-analytics/src/types.ts
6188
6309
  var GA4ApiError = class extends Error {
@@ -6268,7 +6389,8 @@ async function getAccessToken(clientEmail, privateKey) {
6268
6389
  if (!res.ok) {
6269
6390
  const body = await res.text().catch(() => "");
6270
6391
  ga4Log("error", "token.failed", { httpStatus: res.status });
6271
- throw new GA4ApiError(`Failed to get access token: ${body}`, res.status);
6392
+ const detail = body.length <= 200 ? body : `${body.slice(0, 200)}... [truncated]`;
6393
+ throw new GA4ApiError(`Failed to get access token: ${detail}`, res.status);
6272
6394
  }
6273
6395
  const data = await res.json();
6274
6396
  return data.access_token;
@@ -6310,7 +6432,8 @@ async function runReport(accessToken, propertyId, request) {
6310
6432
  if (!res.ok) {
6311
6433
  const body = await res.text();
6312
6434
  ga4Log("error", "report.error", { propertyId, httpStatus: res.status });
6313
- throw new GA4ApiError(`GA4 API error (${res.status}): ${body}`, res.status);
6435
+ const detail = body.length <= 500 ? body : `${body.slice(0, 500)}... [truncated]`;
6436
+ throw new GA4ApiError(`GA4 API error (${res.status}): ${detail}`, res.status);
6314
6437
  }
6315
6438
  return await res.json();
6316
6439
  }
@@ -6340,7 +6463,8 @@ async function batchRunReports(accessToken, propertyId, requests) {
6340
6463
  if (!res.ok) {
6341
6464
  const body = await res.text();
6342
6465
  ga4Log("error", "batch-report.error", { propertyId, httpStatus: res.status });
6343
- throw new GA4ApiError(`GA4 API error (${res.status}): ${body}`, res.status);
6466
+ const detail = body.length <= 500 ? body : `${body.slice(0, 500)}... [truncated]`;
6467
+ throw new GA4ApiError(`GA4 API error (${res.status}): ${detail}`, res.status);
6344
6468
  }
6345
6469
  const data = await res.json();
6346
6470
  return data.reports;
@@ -6371,7 +6495,9 @@ async function fetchTrafficByLandingPage(accessToken, propertyId, days) {
6371
6495
  const PAGE_SIZE = 1e4;
6372
6496
  const rows = [];
6373
6497
  let offset = 0;
6374
- while (true) {
6498
+ let pageCount = 0;
6499
+ while (pageCount < GA4_MAX_PAGES) {
6500
+ pageCount++;
6375
6501
  const request = {
6376
6502
  dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
6377
6503
  dimensions: [
@@ -6401,7 +6527,9 @@ async function fetchTrafficByLandingPage(accessToken, propertyId, days) {
6401
6527
  }
6402
6528
  const organicMap = /* @__PURE__ */ new Map();
6403
6529
  let organicOffset = 0;
6404
- while (true) {
6530
+ let organicPageCount = 0;
6531
+ while (organicPageCount < GA4_MAX_PAGES) {
6532
+ organicPageCount++;
6405
6533
  const organicRequest = {
6406
6534
  dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
6407
6535
  dimensions: [{ name: "date" }, { name: "landingPagePlusQueryString" }],
@@ -6515,7 +6643,9 @@ async function fetchAiReferrals(accessToken, propertyId, days) {
6515
6643
  ];
6516
6644
  for (const [sourceDim, mediumDim, dimLabel] of dimensionPairs) {
6517
6645
  let offset = 0;
6518
- while (true) {
6646
+ let aiRefPageCount = 0;
6647
+ while (aiRefPageCount < GA4_MAX_PAGES) {
6648
+ aiRefPageCount++;
6519
6649
  const request = {
6520
6650
  dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
6521
6651
  dimensions: [
@@ -6578,6 +6708,66 @@ async function fetchAiReferrals(accessToken, propertyId, days) {
6578
6708
  ga4Log("info", "fetch-ai-referrals.done", { propertyId, rowCount: dedupedRows.length });
6579
6709
  return dedupedRows;
6580
6710
  }
6711
+ var SOCIAL_CHANNEL_GROUPS = ["Organic Social", "Paid Social"];
6712
+ async function fetchSocialReferrals(accessToken, propertyId, days) {
6713
+ validateAccessToken2(accessToken);
6714
+ validatePropertyId(propertyId);
6715
+ const syncDays = Math.min(Math.max(1, days ?? GA4_DEFAULT_SYNC_DAYS), GA4_MAX_SYNC_DAYS);
6716
+ const endDate = /* @__PURE__ */ new Date();
6717
+ const startDate = /* @__PURE__ */ new Date();
6718
+ startDate.setDate(startDate.getDate() - syncDays);
6719
+ ga4Log("info", "fetch-social-referrals.start", { propertyId, days: syncDays });
6720
+ const PAGE_SIZE = 1e3;
6721
+ const rows = [];
6722
+ let offset = 0;
6723
+ while (true) {
6724
+ const request = {
6725
+ dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
6726
+ dimensions: [
6727
+ { name: "date" },
6728
+ { name: "sessionSource" },
6729
+ { name: "sessionMedium" },
6730
+ { name: "sessionDefaultChannelGroup" }
6731
+ ],
6732
+ metrics: [
6733
+ { name: "sessions" },
6734
+ { name: "totalUsers" }
6735
+ ],
6736
+ dimensionFilter: {
6737
+ orGroup: {
6738
+ expressions: SOCIAL_CHANNEL_GROUPS.map((value) => ({
6739
+ filter: {
6740
+ fieldName: "sessionDefaultChannelGroup",
6741
+ stringFilter: { matchType: "EXACT", value }
6742
+ }
6743
+ }))
6744
+ }
6745
+ },
6746
+ limit: PAGE_SIZE,
6747
+ offset
6748
+ };
6749
+ const response = await runReport(accessToken, propertyId, request);
6750
+ const pageRows = (response.rows ?? []).map((row) => ({
6751
+ date: row.dimensionValues[0].value,
6752
+ source: row.dimensionValues[1].value,
6753
+ medium: row.dimensionValues[2].value,
6754
+ sessions: parseInt(row.metricValues[0].value, 10) || 0,
6755
+ users: parseInt(row.metricValues[1].value, 10) || 0,
6756
+ channelGroup: row.dimensionValues[3].value
6757
+ }));
6758
+ rows.push(...pageRows);
6759
+ const totalRows = response.rowCount ?? 0;
6760
+ offset += pageRows.length;
6761
+ if (pageRows.length < PAGE_SIZE || offset >= totalRows) break;
6762
+ }
6763
+ for (const row of rows) {
6764
+ if (row.date.length === 8 && !row.date.includes("-")) {
6765
+ row.date = `${row.date.slice(0, 4)}-${row.date.slice(4, 6)}-${row.date.slice(6, 8)}`;
6766
+ }
6767
+ }
6768
+ ga4Log("info", "fetch-social-referrals.done", { propertyId, rowCount: rows.length });
6769
+ return rows;
6770
+ }
6581
6771
 
6582
6772
  // ../api-routes/src/google.ts
6583
6773
  function signState(payload, secret) {
@@ -7380,7 +7570,8 @@ async function bingFetch(apiKey, endpoint, opts) {
7380
7570
  if (!res.ok) {
7381
7571
  const body = await res.text();
7382
7572
  bingClientLog("error", "http.error", { endpoint, method, httpStatus: res.status });
7383
- throw new BingApiError(`Bing API error (${res.status}): ${body}`, res.status);
7573
+ const detail = body.length <= 500 ? body : `${body.slice(0, 500)}... [truncated]`;
7574
+ throw new BingApiError(`Bing API error (${res.status}): ${detail}`, res.status);
7384
7575
  }
7385
7576
  const text = await res.text();
7386
7577
  if (!text || text.trim() === "") {
@@ -7422,6 +7613,12 @@ async function submitUrlBatch(apiKey, siteUrl, urls) {
7422
7613
  validateApiKey(apiKey);
7423
7614
  validateSiteUrl2(siteUrl);
7424
7615
  validateUrls(urls);
7616
+ if (urls.length > BING_SUBMIT_URL_DAILY_LIMIT) {
7617
+ throw new BingApiError(
7618
+ `URL batch exceeds daily limit of ${BING_SUBMIT_URL_DAILY_LIMIT}. Got ${urls.length} URLs.`,
7619
+ 400
7620
+ );
7621
+ }
7425
7622
  for (let i = 0; i < urls.length; i += BING_SUBMIT_URL_BATCH_LIMIT) {
7426
7623
  const batch = urls.slice(i, i + BING_SUBMIT_URL_BATCH_LIMIT);
7427
7624
  await bingFetch(apiKey, "SubmitUrlbatch", {
@@ -7624,6 +7821,22 @@ async function bingRoutes(app, opts) {
7624
7821
  anchorCount: r.anchorCount ?? null,
7625
7822
  discoveryDate: r.discoveryDate ?? null
7626
7823
  });
7824
+ if (total > 0) {
7825
+ const snapshotDate = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
7826
+ const now = (/* @__PURE__ */ new Date()).toISOString();
7827
+ app.db.insert(bingCoverageSnapshots).values({
7828
+ id: crypto15.randomUUID(),
7829
+ projectId: project.id,
7830
+ date: snapshotDate,
7831
+ indexed,
7832
+ notIndexed,
7833
+ unknown,
7834
+ createdAt: now
7835
+ }).onConflictDoUpdate({
7836
+ target: [bingCoverageSnapshots.projectId, bingCoverageSnapshots.date],
7837
+ set: { indexed, notIndexed, unknown, createdAt: now }
7838
+ }).run();
7839
+ }
7627
7840
  return {
7628
7841
  summary: {
7629
7842
  total,
@@ -7638,6 +7851,20 @@ async function bingRoutes(app, opts) {
7638
7851
  unknown: unknownUrls.map(formatRow)
7639
7852
  };
7640
7853
  });
7854
+ app.get("/projects/:name/bing/coverage/history", async (request, reply) => {
7855
+ const store = requireConnectionStore(reply);
7856
+ if (!store) return;
7857
+ const project = resolveProject(app.db, request.params.name);
7858
+ const parsed = parseInt(request.query.limit ?? "90", 10);
7859
+ const limit = Number.isNaN(parsed) || parsed <= 0 ? 90 : parsed;
7860
+ const rows = app.db.select().from(bingCoverageSnapshots).where(eq15(bingCoverageSnapshots.projectId, project.id)).orderBy(desc6(bingCoverageSnapshots.date)).limit(limit).all();
7861
+ return rows.map((r) => ({
7862
+ date: r.date,
7863
+ indexed: r.indexed,
7864
+ notIndexed: r.notIndexed,
7865
+ unknown: r.unknown
7866
+ }));
7867
+ });
7641
7868
  app.get("/projects/:name/bing/inspections", async (request, reply) => {
7642
7869
  const store = requireConnectionStore(reply);
7643
7870
  if (!store) return;
@@ -8163,6 +8390,7 @@ async function ga4Routes(app, opts) {
8163
8390
  app.db.delete(gaTrafficSnapshots).where(eq17(gaTrafficSnapshots.projectId, project.id)).run();
8164
8391
  app.db.delete(gaTrafficSummaries).where(eq17(gaTrafficSummaries.projectId, project.id)).run();
8165
8392
  app.db.delete(gaAiReferrals).where(eq17(gaAiReferrals.projectId, project.id)).run();
8393
+ app.db.delete(gaSocialReferrals).where(eq17(gaSocialReferrals.projectId, project.id)).run();
8166
8394
  const propertyId = saConn?.propertyId ?? oauthConn?.propertyId ?? null;
8167
8395
  opts.ga4CredentialStore?.deleteConnection(project.name);
8168
8396
  opts.googleConnectionStore?.deleteConnection(project.canonicalDomain, "ga4");
@@ -8197,17 +8425,37 @@ async function ga4Routes(app, opts) {
8197
8425
  app.post("/projects/:name/ga/sync", async (request, _reply) => {
8198
8426
  const project = resolveProject(app.db, request.params.name);
8199
8427
  const days = request.body?.days ?? 30;
8428
+ const only = request.body?.only;
8429
+ const validOnlyValues = ["traffic", "ai", "social"];
8430
+ if (only !== void 0 && !validOnlyValues.includes(only)) {
8431
+ throw validationError(`Invalid "only" value "${only}". Must be one of: ${validOnlyValues.join(", ")}`);
8432
+ }
8433
+ const syncTraffic = !only || only === "traffic";
8434
+ const syncAi = !only || only === "ai";
8435
+ const syncSocial = !only || only === "social";
8436
+ const syncSummary = !only;
8200
8437
  const { accessToken, propertyId } = await resolveGa4AccessToken(opts, project.name, project.canonicalDomain);
8201
- let rows;
8438
+ let rows = [];
8202
8439
  let summary;
8203
- let aiReferrals;
8440
+ let aiReferrals = [];
8441
+ let socialReferrals = [];
8204
8442
  try {
8205
- ;
8206
- [rows, summary, aiReferrals] = await Promise.all([
8207
- fetchTrafficByLandingPage(accessToken, propertyId, days),
8208
- fetchAggregateSummary(accessToken, propertyId, days),
8209
- fetchAiReferrals(accessToken, propertyId, days)
8210
- ]);
8443
+ const fetches = [fetchAggregateSummary(accessToken, propertyId, days)];
8444
+ if (syncTraffic) fetches.push(fetchTrafficByLandingPage(accessToken, propertyId, days));
8445
+ if (syncAi) fetches.push(fetchAiReferrals(accessToken, propertyId, days));
8446
+ if (syncSocial) fetches.push(fetchSocialReferrals(accessToken, propertyId, days));
8447
+ const results = await Promise.all(fetches);
8448
+ summary = results[0];
8449
+ let idx = 1;
8450
+ if (syncTraffic) {
8451
+ rows = results[idx++];
8452
+ }
8453
+ if (syncAi) {
8454
+ aiReferrals = results[idx++];
8455
+ }
8456
+ if (syncSocial) {
8457
+ socialReferrals = results[idx++];
8458
+ }
8211
8459
  } catch (e) {
8212
8460
  const msg = e instanceof Error ? e.message : String(e);
8213
8461
  gaLog("error", "sync.fetch-failed", { projectId: project.id, error: msg });
@@ -8215,14 +8463,14 @@ async function ga4Routes(app, opts) {
8215
8463
  }
8216
8464
  const now = (/* @__PURE__ */ new Date()).toISOString();
8217
8465
  app.db.transaction((tx) => {
8218
- tx.delete(gaTrafficSnapshots).where(
8219
- and6(
8220
- eq17(gaTrafficSnapshots.projectId, project.id),
8221
- sql3`${gaTrafficSnapshots.date} >= ${summary.periodStart}`,
8222
- sql3`${gaTrafficSnapshots.date} <= ${summary.periodEnd}`
8223
- )
8224
- ).run();
8225
- if (rows.length > 0) {
8466
+ if (syncTraffic) {
8467
+ tx.delete(gaTrafficSnapshots).where(
8468
+ and6(
8469
+ eq17(gaTrafficSnapshots.projectId, project.id),
8470
+ sql3`${gaTrafficSnapshots.date} >= ${summary.periodStart}`,
8471
+ sql3`${gaTrafficSnapshots.date} <= ${summary.periodEnd}`
8472
+ )
8473
+ ).run();
8226
8474
  for (const row of rows) {
8227
8475
  tx.insert(gaTrafficSnapshots).values({
8228
8476
  id: crypto16.randomUUID(),
@@ -8236,14 +8484,14 @@ async function ga4Routes(app, opts) {
8236
8484
  }).run();
8237
8485
  }
8238
8486
  }
8239
- tx.delete(gaAiReferrals).where(
8240
- and6(
8241
- eq17(gaAiReferrals.projectId, project.id),
8242
- sql3`${gaAiReferrals.date} >= ${summary.periodStart}`,
8243
- sql3`${gaAiReferrals.date} <= ${summary.periodEnd}`
8244
- )
8245
- ).run();
8246
- if (aiReferrals.length > 0) {
8487
+ if (syncAi) {
8488
+ tx.delete(gaAiReferrals).where(
8489
+ and6(
8490
+ eq17(gaAiReferrals.projectId, project.id),
8491
+ sql3`${gaAiReferrals.date} >= ${summary.periodStart}`,
8492
+ sql3`${gaAiReferrals.date} <= ${summary.periodEnd}`
8493
+ )
8494
+ ).run();
8247
8495
  for (const row of aiReferrals) {
8248
8496
  tx.insert(gaAiReferrals).values({
8249
8497
  id: crypto16.randomUUID(),
@@ -8258,31 +8506,60 @@ async function ga4Routes(app, opts) {
8258
8506
  }).run();
8259
8507
  }
8260
8508
  }
8261
- tx.delete(gaTrafficSummaries).where(eq17(gaTrafficSummaries.projectId, project.id)).run();
8262
- tx.insert(gaTrafficSummaries).values({
8263
- id: crypto16.randomUUID(),
8264
- projectId: project.id,
8265
- periodStart: summary.periodStart,
8266
- periodEnd: summary.periodEnd,
8267
- totalSessions: summary.totalSessions,
8268
- totalOrganicSessions: summary.totalOrganicSessions,
8269
- totalUsers: summary.totalUsers,
8270
- syncedAt: now
8271
- }).run();
8509
+ if (syncSocial) {
8510
+ tx.delete(gaSocialReferrals).where(
8511
+ and6(
8512
+ eq17(gaSocialReferrals.projectId, project.id),
8513
+ sql3`${gaSocialReferrals.date} >= ${summary.periodStart}`,
8514
+ sql3`${gaSocialReferrals.date} <= ${summary.periodEnd}`
8515
+ )
8516
+ ).run();
8517
+ for (const row of socialReferrals) {
8518
+ tx.insert(gaSocialReferrals).values({
8519
+ id: crypto16.randomUUID(),
8520
+ projectId: project.id,
8521
+ date: row.date,
8522
+ source: row.source,
8523
+ medium: row.medium,
8524
+ channelGroup: row.channelGroup,
8525
+ sessions: row.sessions,
8526
+ users: row.users,
8527
+ syncedAt: now
8528
+ }).run();
8529
+ }
8530
+ }
8531
+ if (syncSummary) {
8532
+ tx.delete(gaTrafficSummaries).where(eq17(gaTrafficSummaries.projectId, project.id)).run();
8533
+ tx.insert(gaTrafficSummaries).values({
8534
+ id: crypto16.randomUUID(),
8535
+ projectId: project.id,
8536
+ periodStart: summary.periodStart,
8537
+ periodEnd: summary.periodEnd,
8538
+ totalSessions: summary.totalSessions,
8539
+ totalOrganicSessions: summary.totalOrganicSessions,
8540
+ totalUsers: summary.totalUsers,
8541
+ syncedAt: now
8542
+ }).run();
8543
+ }
8272
8544
  });
8545
+ const syncedComponents = only ? [only, ...only !== "social" && only !== "ai" && only !== "traffic" ? [] : []] : void 0;
8273
8546
  gaLog("info", "sync.complete", {
8274
8547
  projectId: project.id,
8275
8548
  rowCount: rows.length,
8276
8549
  aiReferralCount: aiReferrals.length,
8550
+ socialReferralCount: socialReferrals.length,
8277
8551
  days,
8278
- totalUsers: summary.totalUsers
8552
+ totalUsers: summary.totalUsers,
8553
+ ...only ? { only } : {}
8279
8554
  });
8280
8555
  return {
8281
8556
  synced: true,
8282
8557
  rowCount: rows.length,
8283
8558
  aiReferralCount: aiReferrals.length,
8559
+ socialReferralCount: socialReferrals.length,
8284
8560
  days,
8285
- syncedAt: now
8561
+ syncedAt: now,
8562
+ ...syncedComponents ? { syncedComponents } : {}
8286
8563
  };
8287
8564
  });
8288
8565
  app.get("/projects/:name/ga/traffic", async (request, _reply) => {
@@ -8320,9 +8597,21 @@ async function ga4Routes(app, opts) {
8320
8597
  GROUP BY date, source, medium
8321
8598
  )`
8322
8599
  ).get();
8600
+ const socialReferrals = app.db.select({
8601
+ source: gaSocialReferrals.source,
8602
+ medium: gaSocialReferrals.medium,
8603
+ channelGroup: gaSocialReferrals.channelGroup,
8604
+ sessions: sql3`SUM(${gaSocialReferrals.sessions})`,
8605
+ users: sql3`SUM(${gaSocialReferrals.users})`
8606
+ }).from(gaSocialReferrals).where(eq17(gaSocialReferrals.projectId, project.id)).groupBy(gaSocialReferrals.source, gaSocialReferrals.medium, gaSocialReferrals.channelGroup).orderBy(sql3`SUM(${gaSocialReferrals.sessions}) DESC`).all();
8607
+ const socialTotals = app.db.select({
8608
+ sessions: sql3`SUM(${gaSocialReferrals.sessions})`,
8609
+ users: sql3`SUM(${gaSocialReferrals.users})`
8610
+ }).from(gaSocialReferrals).where(eq17(gaSocialReferrals.projectId, project.id)).get();
8323
8611
  const latestSync = app.db.select({ syncedAt: gaTrafficSummaries.syncedAt }).from(gaTrafficSummaries).where(eq17(gaTrafficSummaries.projectId, project.id)).orderBy(desc7(gaTrafficSummaries.syncedAt)).limit(1).get();
8612
+ const total = summary?.totalSessions ?? 0;
8324
8613
  return {
8325
- totalSessions: summary?.totalSessions ?? 0,
8614
+ totalSessions: total,
8326
8615
  totalOrganicSessions: summary?.totalOrganicSessions ?? 0,
8327
8616
  totalUsers: summary?.totalUsers ?? 0,
8328
8617
  topPages: rows.map((r) => ({
@@ -8340,6 +8629,18 @@ async function ga4Routes(app, opts) {
8340
8629
  })),
8341
8630
  aiSessionsDeduped: aiDeduped?.sessions ?? 0,
8342
8631
  aiUsersDeduped: aiDeduped?.users ?? 0,
8632
+ socialReferrals: socialReferrals.map((r) => ({
8633
+ source: r.source,
8634
+ medium: r.medium,
8635
+ channelGroup: r.channelGroup,
8636
+ sessions: r.sessions ?? 0,
8637
+ users: r.users ?? 0
8638
+ })),
8639
+ socialSessions: socialTotals?.sessions ?? 0,
8640
+ socialUsers: socialTotals?.users ?? 0,
8641
+ organicSharePct: total > 0 ? Math.round((summary?.totalOrganicSessions ?? 0) / total * 100) : 0,
8642
+ aiSharePct: total > 0 ? Math.round((aiDeduped?.sessions ?? 0) / total * 100) : 0,
8643
+ socialSharePct: total > 0 ? Math.round((socialTotals?.sessions ?? 0) / total * 100) : 0,
8343
8644
  lastSyncedAt: latestSync?.syncedAt ?? null
8344
8645
  };
8345
8646
  });
@@ -8356,6 +8657,146 @@ async function ga4Routes(app, opts) {
8356
8657
  }).from(gaAiReferrals).where(eq17(gaAiReferrals.projectId, project.id)).orderBy(gaAiReferrals.date).all();
8357
8658
  return rows;
8358
8659
  });
8660
+ app.get("/projects/:name/ga/social-referral-history", async (request, _reply) => {
8661
+ const project = resolveProject(app.db, request.params.name);
8662
+ requireGa4Connection(opts, project.name, project.canonicalDomain);
8663
+ const rows = app.db.select({
8664
+ date: gaSocialReferrals.date,
8665
+ source: gaSocialReferrals.source,
8666
+ medium: gaSocialReferrals.medium,
8667
+ channelGroup: gaSocialReferrals.channelGroup,
8668
+ sessions: gaSocialReferrals.sessions,
8669
+ users: gaSocialReferrals.users
8670
+ }).from(gaSocialReferrals).where(eq17(gaSocialReferrals.projectId, project.id)).orderBy(gaSocialReferrals.date).all();
8671
+ return rows;
8672
+ });
8673
+ app.get("/projects/:name/ga/social-referral-trend", async (request, _reply) => {
8674
+ const project = resolveProject(app.db, request.params.name);
8675
+ requireGa4Connection(opts, project.name, project.canonicalDomain);
8676
+ const today = /* @__PURE__ */ new Date();
8677
+ const fmt = (d) => d.toISOString().split("T")[0];
8678
+ const daysAgo2 = (n) => {
8679
+ const d = new Date(today);
8680
+ d.setDate(d.getDate() - n);
8681
+ return fmt(d);
8682
+ };
8683
+ const sumSocial = (from, to) => app.db.select({ sessions: sql3`COALESCE(SUM(${gaSocialReferrals.sessions}), 0)` }).from(gaSocialReferrals).where(and6(
8684
+ eq17(gaSocialReferrals.projectId, project.id),
8685
+ sql3`${gaSocialReferrals.date} >= ${from}`,
8686
+ sql3`${gaSocialReferrals.date} < ${to}`
8687
+ )).get();
8688
+ const current7d = sumSocial(daysAgo2(7), fmt(today));
8689
+ const prev7d = sumSocial(daysAgo2(14), daysAgo2(7));
8690
+ const current30d = sumSocial(daysAgo2(30), fmt(today));
8691
+ const prev30d = sumSocial(daysAgo2(60), daysAgo2(30));
8692
+ const pct = (cur, prev) => prev === 0 ? null : Math.round((cur - prev) / prev * 100);
8693
+ const sourceCurrent = app.db.select({
8694
+ source: gaSocialReferrals.source,
8695
+ sessions: sql3`SUM(${gaSocialReferrals.sessions})`
8696
+ }).from(gaSocialReferrals).where(and6(
8697
+ eq17(gaSocialReferrals.projectId, project.id),
8698
+ sql3`${gaSocialReferrals.date} >= ${daysAgo2(7)}`,
8699
+ sql3`${gaSocialReferrals.date} < ${fmt(today)}`
8700
+ )).groupBy(gaSocialReferrals.source).all();
8701
+ const sourcePrev = app.db.select({
8702
+ source: gaSocialReferrals.source,
8703
+ sessions: sql3`SUM(${gaSocialReferrals.sessions})`
8704
+ }).from(gaSocialReferrals).where(and6(
8705
+ eq17(gaSocialReferrals.projectId, project.id),
8706
+ sql3`${gaSocialReferrals.date} >= ${daysAgo2(14)}`,
8707
+ sql3`${gaSocialReferrals.date} < ${daysAgo2(7)}`
8708
+ )).groupBy(gaSocialReferrals.source).all();
8709
+ const prevMap = new Map(sourcePrev.map((r) => [r.source, r.sessions]));
8710
+ let biggestMover = null;
8711
+ let maxDelta = 0;
8712
+ for (const row of sourceCurrent) {
8713
+ const prev = prevMap.get(row.source) ?? 0;
8714
+ const delta = Math.abs(row.sessions - prev);
8715
+ if (delta > maxDelta) {
8716
+ maxDelta = delta;
8717
+ biggestMover = {
8718
+ source: row.source,
8719
+ sessions7d: row.sessions,
8720
+ sessionsPrev7d: prev,
8721
+ changePct: pct(row.sessions, prev) ?? (row.sessions > 0 ? 100 : 0)
8722
+ };
8723
+ }
8724
+ }
8725
+ return {
8726
+ socialSessions7d: current7d?.sessions ?? 0,
8727
+ socialSessionsPrev7d: prev7d?.sessions ?? 0,
8728
+ trend7dPct: pct(current7d?.sessions ?? 0, prev7d?.sessions ?? 0),
8729
+ socialSessions30d: current30d?.sessions ?? 0,
8730
+ socialSessionsPrev30d: prev30d?.sessions ?? 0,
8731
+ trend30dPct: pct(current30d?.sessions ?? 0, prev30d?.sessions ?? 0),
8732
+ biggestMover
8733
+ };
8734
+ });
8735
+ app.get("/projects/:name/ga/attribution-trend", async (request, _reply) => {
8736
+ const project = resolveProject(app.db, request.params.name);
8737
+ requireGa4Connection(opts, project.name, project.canonicalDomain);
8738
+ const today = /* @__PURE__ */ new Date();
8739
+ const fmt = (d) => d.toISOString().split("T")[0];
8740
+ const daysAgo2 = (n) => {
8741
+ const d = new Date(today);
8742
+ d.setDate(d.getDate() - n);
8743
+ return fmt(d);
8744
+ };
8745
+ const pct = (cur, prev) => prev === 0 ? null : Math.round((cur - prev) / prev * 100);
8746
+ const sumTotal = (from, to) => app.db.select({ sessions: sql3`COALESCE(SUM(${gaTrafficSnapshots.sessions}), 0)` }).from(gaTrafficSnapshots).where(and6(eq17(gaTrafficSnapshots.projectId, project.id), sql3`${gaTrafficSnapshots.date} >= ${from}`, sql3`${gaTrafficSnapshots.date} < ${to}`)).get();
8747
+ const sumOrganic = (from, to) => app.db.select({ sessions: sql3`COALESCE(SUM(${gaTrafficSnapshots.organicSessions}), 0)` }).from(gaTrafficSnapshots).where(and6(eq17(gaTrafficSnapshots.projectId, project.id), sql3`${gaTrafficSnapshots.date} >= ${from}`, sql3`${gaTrafficSnapshots.date} < ${to}`)).get();
8748
+ const sumAi = (from, to) => app.db.select({ sessions: sql3`COALESCE(SUM(max_sessions), 0)` }).from(sql3`(
8749
+ SELECT date, source, medium, MAX(sessions) AS max_sessions
8750
+ FROM ga_ai_referrals
8751
+ WHERE project_id = ${project.id} AND date >= ${from} AND date < ${to}
8752
+ GROUP BY date, source, medium
8753
+ )`).get();
8754
+ const sumSocial = (from, to) => app.db.select({ sessions: sql3`COALESCE(SUM(${gaSocialReferrals.sessions}), 0)` }).from(gaSocialReferrals).where(and6(eq17(gaSocialReferrals.projectId, project.id), sql3`${gaSocialReferrals.date} >= ${from}`, sql3`${gaSocialReferrals.date} < ${to}`)).get();
8755
+ const todayStr = fmt(today);
8756
+ const buildTrend = (sum) => {
8757
+ const c7 = sum(daysAgo2(7), todayStr)?.sessions ?? 0;
8758
+ const p7 = sum(daysAgo2(14), daysAgo2(7))?.sessions ?? 0;
8759
+ const c30 = sum(daysAgo2(30), todayStr)?.sessions ?? 0;
8760
+ const p30 = sum(daysAgo2(60), daysAgo2(30))?.sessions ?? 0;
8761
+ return { sessions7d: c7, sessionsPrev7d: p7, trend7dPct: pct(c7, p7), sessions30d: c30, sessionsPrev30d: p30, trend30dPct: pct(c30, p30) };
8762
+ };
8763
+ const aiSourceCurrent = app.db.select({ source: sql3`source`, sessions: sql3`COALESCE(SUM(max_sessions), 0)` }).from(sql3`(
8764
+ SELECT date, source, medium, MAX(sessions) AS max_sessions
8765
+ FROM ga_ai_referrals
8766
+ WHERE project_id = ${project.id} AND date >= ${daysAgo2(7)} AND date < ${todayStr}
8767
+ GROUP BY date, source, medium
8768
+ )`).groupBy(sql3`source`).all();
8769
+ const aiSourcePrev = app.db.select({ source: sql3`source`, sessions: sql3`COALESCE(SUM(max_sessions), 0)` }).from(sql3`(
8770
+ SELECT date, source, medium, MAX(sessions) AS max_sessions
8771
+ FROM ga_ai_referrals
8772
+ WHERE project_id = ${project.id} AND date >= ${daysAgo2(14)} AND date < ${daysAgo2(7)}
8773
+ GROUP BY date, source, medium
8774
+ )`).groupBy(sql3`source`).all();
8775
+ const findBiggestMover = (current, prev) => {
8776
+ const prevMap = new Map(prev.map((r) => [r.source, r.sessions]));
8777
+ let mover = null;
8778
+ let maxDelta = 0;
8779
+ for (const row of current) {
8780
+ const p = prevMap.get(row.source) ?? 0;
8781
+ const delta = Math.abs(row.sessions - p);
8782
+ if (delta > maxDelta) {
8783
+ maxDelta = delta;
8784
+ mover = { source: row.source, sessions7d: row.sessions, sessionsPrev7d: p, changePct: pct(row.sessions, p) ?? (row.sessions > 0 ? 100 : 0) };
8785
+ }
8786
+ }
8787
+ return mover;
8788
+ };
8789
+ const socialSourceCurrent = app.db.select({ source: gaSocialReferrals.source, sessions: sql3`SUM(${gaSocialReferrals.sessions})` }).from(gaSocialReferrals).where(and6(eq17(gaSocialReferrals.projectId, project.id), sql3`${gaSocialReferrals.date} >= ${daysAgo2(7)}`, sql3`${gaSocialReferrals.date} < ${todayStr}`)).groupBy(gaSocialReferrals.source).all();
8790
+ const socialSourcePrev = app.db.select({ source: gaSocialReferrals.source, sessions: sql3`SUM(${gaSocialReferrals.sessions})` }).from(gaSocialReferrals).where(and6(eq17(gaSocialReferrals.projectId, project.id), sql3`${gaSocialReferrals.date} >= ${daysAgo2(14)}`, sql3`${gaSocialReferrals.date} < ${daysAgo2(7)}`)).groupBy(gaSocialReferrals.source).all();
8791
+ return {
8792
+ total: buildTrend(sumTotal),
8793
+ organic: buildTrend(sumOrganic),
8794
+ ai: buildTrend(sumAi),
8795
+ social: buildTrend(sumSocial),
8796
+ aiBiggestMover: findBiggestMover(aiSourceCurrent, aiSourcePrev),
8797
+ socialBiggestMover: findBiggestMover(socialSourceCurrent, socialSourcePrev)
8798
+ };
8799
+ });
8359
8800
  app.get("/projects/:name/ga/session-history", async (request, _reply) => {
8360
8801
  const project = resolveProject(app.db, request.params.name);
8361
8802
  requireGa4Connection(opts, project.name, project.canonicalDomain);