@ainyc/canonry 1.41.0 → 1.44.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.
@@ -6,6 +6,7 @@ import {
6
6
  competitors,
7
7
  createLogger,
8
8
  gaAiReferrals,
9
+ gaSocialReferrals,
9
10
  gaTrafficSnapshots,
10
11
  gaTrafficSummaries,
11
12
  gscCoverageSnapshots,
@@ -21,7 +22,7 @@ import {
21
22
  runs,
22
23
  schedules,
23
24
  usageCounters
24
- } from "./chunk-EUBC5EGC.js";
25
+ } from "./chunk-AATIMNOX.js";
25
26
 
26
27
  // src/config.ts
27
28
  import fs from "fs";
@@ -1101,6 +1102,14 @@ var ga4AiReferralDtoSchema = z12.object({
1101
1102
  users: z12.number(),
1102
1103
  sourceDimension: ga4SourceDimensionSchema
1103
1104
  });
1105
+ var ga4SocialReferralDtoSchema = z12.object({
1106
+ source: z12.string(),
1107
+ medium: z12.string(),
1108
+ sessions: z12.number(),
1109
+ users: z12.number(),
1110
+ /** GA4 default channel group (e.g. 'Organic Social', 'Paid Social') */
1111
+ channelGroup: z12.string()
1112
+ });
1104
1113
  var ga4TrafficSummaryDtoSchema = z12.object({
1105
1114
  totalSessions: z12.number(),
1106
1115
  totalOrganicSessions: z12.number(),
@@ -1116,6 +1125,17 @@ var ga4TrafficSummaryDtoSchema = z12.object({
1116
1125
  aiSessionsDeduped: z12.number(),
1117
1126
  /** Deduped AI user total: MAX(users) per date+source+medium across attribution dimensions, then summed. */
1118
1127
  aiUsersDeduped: z12.number(),
1128
+ socialReferrals: z12.array(ga4SocialReferralDtoSchema),
1129
+ /** Total social sessions (session-scoped, no cross-dimension dedup needed). */
1130
+ socialSessions: z12.number(),
1131
+ /** Total social users (session-scoped, no cross-dimension dedup needed). */
1132
+ socialUsers: z12.number(),
1133
+ /** Organic sessions as a percentage of total sessions (0–100, rounded). */
1134
+ organicSharePct: z12.number(),
1135
+ /** Deduped AI sessions as a percentage of total sessions (0–100, rounded). */
1136
+ aiSharePct: z12.number(),
1137
+ /** Social sessions as a percentage of total sessions (0–100, rounded). */
1138
+ socialSharePct: z12.number(),
1119
1139
  lastSyncedAt: z12.string().nullable()
1120
1140
  });
1121
1141
  var ga4AiReferralHistoryEntrySchema = z12.object({
@@ -1127,6 +1147,15 @@ var ga4AiReferralHistoryEntrySchema = z12.object({
1127
1147
  /** Which GA4 dimension this row came from: session (sessionSource), first_user (firstUserSource), or manual_utm (utm_source parameter) */
1128
1148
  sourceDimension: ga4SourceDimensionSchema
1129
1149
  });
1150
+ var ga4SocialReferralHistoryEntrySchema = z12.object({
1151
+ date: z12.string(),
1152
+ source: z12.string(),
1153
+ medium: z12.string(),
1154
+ sessions: z12.number(),
1155
+ users: z12.number(),
1156
+ /** GA4 default channel group (e.g. 'Organic Social', 'Paid Social') */
1157
+ channelGroup: z12.string()
1158
+ });
1130
1159
  var ga4SessionHistoryEntrySchema = z12.object({
1131
1160
  date: z12.string(),
1132
1161
  sessions: z12.number(),
@@ -5296,6 +5325,42 @@ var routeCatalog = [
5296
5325
  404: { description: "Project not found." }
5297
5326
  }
5298
5327
  },
5328
+ {
5329
+ method: "get",
5330
+ path: "/api/v1/projects/{name}/ga/social-referral-history",
5331
+ summary: "Get social media referral sessions per day grouped by source",
5332
+ tags: ["ga4"],
5333
+ parameters: [nameParameter],
5334
+ responses: {
5335
+ 200: { description: "Social referral history returned." },
5336
+ 400: { description: "GA4 is not connected." },
5337
+ 404: { description: "Project not found." }
5338
+ }
5339
+ },
5340
+ {
5341
+ method: "get",
5342
+ path: "/api/v1/projects/{name}/ga/social-referral-trend",
5343
+ summary: "Get social referral trend (7d/30d) with biggest mover",
5344
+ tags: ["ga4"],
5345
+ parameters: [nameParameter],
5346
+ responses: {
5347
+ 200: { description: "Social referral trend returned." },
5348
+ 400: { description: "GA4 is not connected." },
5349
+ 404: { description: "Project not found." }
5350
+ }
5351
+ },
5352
+ {
5353
+ method: "get",
5354
+ path: "/api/v1/projects/{name}/ga/attribution-trend",
5355
+ summary: "Get per-channel attribution trends (7d/30d) for organic, AI, and social",
5356
+ tags: ["ga4"],
5357
+ parameters: [nameParameter],
5358
+ responses: {
5359
+ 200: { description: "Attribution trend returned." },
5360
+ 400: { description: "GA4 is not connected." },
5361
+ 404: { description: "Project not found." }
5362
+ }
5363
+ },
5299
5364
  {
5300
5365
  method: "get",
5301
5366
  path: "/api/v1/projects/{name}/ga/session-history",
@@ -5891,6 +5956,7 @@ var GSC_DATA_LAG_DAYS = 3;
5891
5956
  var INDEXING_API_BASE = "https://indexing.googleapis.com/v3";
5892
5957
  var INDEXING_API_DAILY_LIMIT = 200;
5893
5958
  var GOOGLE_REQUEST_TIMEOUT_MS = 3e4;
5959
+ var GSC_MAX_PAGES = 40;
5894
5960
 
5895
5961
  // ../integration-google/src/types.ts
5896
5962
  var GoogleAuthError = class extends Error {
@@ -5989,7 +6055,15 @@ async function exchangeCode(clientId, clientSecret, code, redirectUri) {
5989
6055
  });
5990
6056
  if (!res.ok) {
5991
6057
  const body = await res.text();
5992
- throw new GoogleAuthError(`Token exchange failed (${res.status}): ${body}`);
6058
+ let detail = "";
6059
+ try {
6060
+ const parsed = JSON.parse(body);
6061
+ if (parsed.error) detail = parsed.error;
6062
+ if (parsed.error_description) detail += detail ? `: ${parsed.error_description}` : parsed.error_description;
6063
+ } catch {
6064
+ detail = body.length <= 120 ? body : `${body.slice(0, 120)}...`;
6065
+ }
6066
+ throw new GoogleAuthError(`Token exchange failed (${res.status}): ${detail}`);
5993
6067
  }
5994
6068
  return await res.json();
5995
6069
  }
@@ -6010,7 +6084,15 @@ async function refreshAccessToken(clientId, clientSecret, currentRefreshToken) {
6010
6084
  });
6011
6085
  if (!res.ok) {
6012
6086
  const body = await res.text();
6013
- throw new GoogleAuthError(`Token refresh failed (${res.status}): ${body}`);
6087
+ let detail = "";
6088
+ try {
6089
+ const parsed = JSON.parse(body);
6090
+ if (parsed.error) detail = parsed.error;
6091
+ if (parsed.error_description) detail += detail ? `: ${parsed.error_description}` : parsed.error_description;
6092
+ } catch {
6093
+ detail = body.length <= 120 ? body : `${body.slice(0, 120)}...`;
6094
+ }
6095
+ throw new GoogleAuthError(`Token refresh failed (${res.status}): ${detail}`);
6014
6096
  }
6015
6097
  return await res.json();
6016
6098
  }
@@ -6057,6 +6139,18 @@ function validateUrl(urlParam) {
6057
6139
  throw new GoogleApiError("URL must be a valid URL", 400);
6058
6140
  }
6059
6141
  }
6142
+ function validateDate(date, label) {
6143
+ if (!date || typeof date !== "string" || date.trim().length === 0) {
6144
+ throw new GoogleApiError(`${label} is required and must be a non-empty string`, 400);
6145
+ }
6146
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
6147
+ throw new GoogleApiError(`${label} must be in YYYY-MM-DD format, got "${date}"`, 400);
6148
+ }
6149
+ const parsed = /* @__PURE__ */ new Date(`${date}T00:00:00Z`);
6150
+ if (Number.isNaN(parsed.getTime())) {
6151
+ throw new GoogleApiError(`${label} is not a valid date, got "${date}"`, 400);
6152
+ }
6153
+ }
6060
6154
  function gscClientLog(level, action, ctx) {
6061
6155
  const entry = { ts: (/* @__PURE__ */ new Date()).toISOString(), level, module: "GscClient", action, ...ctx };
6062
6156
  const stream = level === "error" ? process.stderr : process.stdout;
@@ -6085,7 +6179,8 @@ async function gscFetch(accessToken, url, opts) {
6085
6179
  if (!res.ok) {
6086
6180
  const body = await res.text();
6087
6181
  gscClientLog("error", "http.error", { url, method, httpStatus: res.status });
6088
- throw new GoogleApiError(`GSC API error (${res.status}): ${body}`, res.status);
6182
+ const detail = body.length <= 500 ? body : `${body.slice(0, 500)}... [truncated]`;
6183
+ throw new GoogleApiError(`GSC API error (${res.status}): ${detail}`, res.status);
6089
6184
  }
6090
6185
  return await res.json();
6091
6186
  }
@@ -6110,10 +6205,16 @@ async function listSitemaps(accessToken, siteUrl) {
6110
6205
  async function fetchSearchAnalytics(accessToken, siteUrl, opts) {
6111
6206
  validateAccessToken(accessToken);
6112
6207
  validateSiteUrl(siteUrl);
6208
+ validateDate(opts.startDate, "startDate");
6209
+ validateDate(opts.endDate, "endDate");
6113
6210
  const allRows = [];
6114
6211
  let startRow = 0;
6115
6212
  const dimensions = opts.dimensions ?? ["query", "page", "country", "device", "date"];
6116
6213
  for (; ; ) {
6214
+ if (startRow >= GSC_MAX_ROWS_PER_REQUEST * GSC_MAX_PAGES) {
6215
+ gscClientLog("error", "pagination.safety-limit", { siteUrl, startRow, maxRows: GSC_MAX_ROWS_PER_REQUEST * GSC_MAX_PAGES });
6216
+ break;
6217
+ }
6117
6218
  const requestBody = {
6118
6219
  startDate: opts.startDate,
6119
6220
  endDate: opts.endDate,
@@ -6183,6 +6284,7 @@ var GOOGLE_TOKEN_URL2 = "https://oauth2.googleapis.com/token";
6183
6284
  var GA4_DEFAULT_SYNC_DAYS = 30;
6184
6285
  var GA4_MAX_SYNC_DAYS = 90;
6185
6286
  var GA4_REQUEST_TIMEOUT_MS = 3e4;
6287
+ var GA4_MAX_PAGES = 50;
6186
6288
 
6187
6289
  // ../integration-google-analytics/src/types.ts
6188
6290
  var GA4ApiError = class extends Error {
@@ -6268,7 +6370,8 @@ async function getAccessToken(clientEmail, privateKey) {
6268
6370
  if (!res.ok) {
6269
6371
  const body = await res.text().catch(() => "");
6270
6372
  ga4Log("error", "token.failed", { httpStatus: res.status });
6271
- throw new GA4ApiError(`Failed to get access token: ${body}`, res.status);
6373
+ const detail = body.length <= 200 ? body : `${body.slice(0, 200)}... [truncated]`;
6374
+ throw new GA4ApiError(`Failed to get access token: ${detail}`, res.status);
6272
6375
  }
6273
6376
  const data = await res.json();
6274
6377
  return data.access_token;
@@ -6310,7 +6413,8 @@ async function runReport(accessToken, propertyId, request) {
6310
6413
  if (!res.ok) {
6311
6414
  const body = await res.text();
6312
6415
  ga4Log("error", "report.error", { propertyId, httpStatus: res.status });
6313
- throw new GA4ApiError(`GA4 API error (${res.status}): ${body}`, res.status);
6416
+ const detail = body.length <= 500 ? body : `${body.slice(0, 500)}... [truncated]`;
6417
+ throw new GA4ApiError(`GA4 API error (${res.status}): ${detail}`, res.status);
6314
6418
  }
6315
6419
  return await res.json();
6316
6420
  }
@@ -6340,7 +6444,8 @@ async function batchRunReports(accessToken, propertyId, requests) {
6340
6444
  if (!res.ok) {
6341
6445
  const body = await res.text();
6342
6446
  ga4Log("error", "batch-report.error", { propertyId, httpStatus: res.status });
6343
- throw new GA4ApiError(`GA4 API error (${res.status}): ${body}`, res.status);
6447
+ const detail = body.length <= 500 ? body : `${body.slice(0, 500)}... [truncated]`;
6448
+ throw new GA4ApiError(`GA4 API error (${res.status}): ${detail}`, res.status);
6344
6449
  }
6345
6450
  const data = await res.json();
6346
6451
  return data.reports;
@@ -6371,7 +6476,9 @@ async function fetchTrafficByLandingPage(accessToken, propertyId, days) {
6371
6476
  const PAGE_SIZE = 1e4;
6372
6477
  const rows = [];
6373
6478
  let offset = 0;
6374
- while (true) {
6479
+ let pageCount = 0;
6480
+ while (pageCount < GA4_MAX_PAGES) {
6481
+ pageCount++;
6375
6482
  const request = {
6376
6483
  dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
6377
6484
  dimensions: [
@@ -6401,7 +6508,9 @@ async function fetchTrafficByLandingPage(accessToken, propertyId, days) {
6401
6508
  }
6402
6509
  const organicMap = /* @__PURE__ */ new Map();
6403
6510
  let organicOffset = 0;
6404
- while (true) {
6511
+ let organicPageCount = 0;
6512
+ while (organicPageCount < GA4_MAX_PAGES) {
6513
+ organicPageCount++;
6405
6514
  const organicRequest = {
6406
6515
  dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
6407
6516
  dimensions: [{ name: "date" }, { name: "landingPagePlusQueryString" }],
@@ -6515,7 +6624,9 @@ async function fetchAiReferrals(accessToken, propertyId, days) {
6515
6624
  ];
6516
6625
  for (const [sourceDim, mediumDim, dimLabel] of dimensionPairs) {
6517
6626
  let offset = 0;
6518
- while (true) {
6627
+ let aiRefPageCount = 0;
6628
+ while (aiRefPageCount < GA4_MAX_PAGES) {
6629
+ aiRefPageCount++;
6519
6630
  const request = {
6520
6631
  dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
6521
6632
  dimensions: [
@@ -6578,6 +6689,66 @@ async function fetchAiReferrals(accessToken, propertyId, days) {
6578
6689
  ga4Log("info", "fetch-ai-referrals.done", { propertyId, rowCount: dedupedRows.length });
6579
6690
  return dedupedRows;
6580
6691
  }
6692
+ var SOCIAL_CHANNEL_GROUPS = ["Organic Social", "Paid Social"];
6693
+ async function fetchSocialReferrals(accessToken, propertyId, days) {
6694
+ validateAccessToken2(accessToken);
6695
+ validatePropertyId(propertyId);
6696
+ const syncDays = Math.min(Math.max(1, days ?? GA4_DEFAULT_SYNC_DAYS), GA4_MAX_SYNC_DAYS);
6697
+ const endDate = /* @__PURE__ */ new Date();
6698
+ const startDate = /* @__PURE__ */ new Date();
6699
+ startDate.setDate(startDate.getDate() - syncDays);
6700
+ ga4Log("info", "fetch-social-referrals.start", { propertyId, days: syncDays });
6701
+ const PAGE_SIZE = 1e3;
6702
+ const rows = [];
6703
+ let offset = 0;
6704
+ while (true) {
6705
+ const request = {
6706
+ dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
6707
+ dimensions: [
6708
+ { name: "date" },
6709
+ { name: "sessionSource" },
6710
+ { name: "sessionMedium" },
6711
+ { name: "sessionDefaultChannelGroup" }
6712
+ ],
6713
+ metrics: [
6714
+ { name: "sessions" },
6715
+ { name: "totalUsers" }
6716
+ ],
6717
+ dimensionFilter: {
6718
+ orGroup: {
6719
+ expressions: SOCIAL_CHANNEL_GROUPS.map((value) => ({
6720
+ filter: {
6721
+ fieldName: "sessionDefaultChannelGroup",
6722
+ stringFilter: { matchType: "EXACT", value }
6723
+ }
6724
+ }))
6725
+ }
6726
+ },
6727
+ limit: PAGE_SIZE,
6728
+ offset
6729
+ };
6730
+ const response = await runReport(accessToken, propertyId, request);
6731
+ const pageRows = (response.rows ?? []).map((row) => ({
6732
+ date: row.dimensionValues[0].value,
6733
+ source: row.dimensionValues[1].value,
6734
+ medium: row.dimensionValues[2].value,
6735
+ sessions: parseInt(row.metricValues[0].value, 10) || 0,
6736
+ users: parseInt(row.metricValues[1].value, 10) || 0,
6737
+ channelGroup: row.dimensionValues[3].value
6738
+ }));
6739
+ rows.push(...pageRows);
6740
+ const totalRows = response.rowCount ?? 0;
6741
+ offset += pageRows.length;
6742
+ if (pageRows.length < PAGE_SIZE || offset >= totalRows) break;
6743
+ }
6744
+ for (const row of rows) {
6745
+ if (row.date.length === 8 && !row.date.includes("-")) {
6746
+ row.date = `${row.date.slice(0, 4)}-${row.date.slice(4, 6)}-${row.date.slice(6, 8)}`;
6747
+ }
6748
+ }
6749
+ ga4Log("info", "fetch-social-referrals.done", { propertyId, rowCount: rows.length });
6750
+ return rows;
6751
+ }
6581
6752
 
6582
6753
  // ../api-routes/src/google.ts
6583
6754
  function signState(payload, secret) {
@@ -7380,7 +7551,8 @@ async function bingFetch(apiKey, endpoint, opts) {
7380
7551
  if (!res.ok) {
7381
7552
  const body = await res.text();
7382
7553
  bingClientLog("error", "http.error", { endpoint, method, httpStatus: res.status });
7383
- throw new BingApiError(`Bing API error (${res.status}): ${body}`, res.status);
7554
+ const detail = body.length <= 500 ? body : `${body.slice(0, 500)}... [truncated]`;
7555
+ throw new BingApiError(`Bing API error (${res.status}): ${detail}`, res.status);
7384
7556
  }
7385
7557
  const text = await res.text();
7386
7558
  if (!text || text.trim() === "") {
@@ -7422,6 +7594,12 @@ async function submitUrlBatch(apiKey, siteUrl, urls) {
7422
7594
  validateApiKey(apiKey);
7423
7595
  validateSiteUrl2(siteUrl);
7424
7596
  validateUrls(urls);
7597
+ if (urls.length > BING_SUBMIT_URL_DAILY_LIMIT) {
7598
+ throw new BingApiError(
7599
+ `URL batch exceeds daily limit of ${BING_SUBMIT_URL_DAILY_LIMIT}. Got ${urls.length} URLs.`,
7600
+ 400
7601
+ );
7602
+ }
7425
7603
  for (let i = 0; i < urls.length; i += BING_SUBMIT_URL_BATCH_LIMIT) {
7426
7604
  const batch = urls.slice(i, i + BING_SUBMIT_URL_BATCH_LIMIT);
7427
7605
  await bingFetch(apiKey, "SubmitUrlbatch", {
@@ -8163,6 +8341,7 @@ async function ga4Routes(app, opts) {
8163
8341
  app.db.delete(gaTrafficSnapshots).where(eq17(gaTrafficSnapshots.projectId, project.id)).run();
8164
8342
  app.db.delete(gaTrafficSummaries).where(eq17(gaTrafficSummaries.projectId, project.id)).run();
8165
8343
  app.db.delete(gaAiReferrals).where(eq17(gaAiReferrals.projectId, project.id)).run();
8344
+ app.db.delete(gaSocialReferrals).where(eq17(gaSocialReferrals.projectId, project.id)).run();
8166
8345
  const propertyId = saConn?.propertyId ?? oauthConn?.propertyId ?? null;
8167
8346
  opts.ga4CredentialStore?.deleteConnection(project.name);
8168
8347
  opts.googleConnectionStore?.deleteConnection(project.canonicalDomain, "ga4");
@@ -8197,17 +8376,37 @@ async function ga4Routes(app, opts) {
8197
8376
  app.post("/projects/:name/ga/sync", async (request, _reply) => {
8198
8377
  const project = resolveProject(app.db, request.params.name);
8199
8378
  const days = request.body?.days ?? 30;
8379
+ const only = request.body?.only;
8380
+ const validOnlyValues = ["traffic", "ai", "social"];
8381
+ if (only !== void 0 && !validOnlyValues.includes(only)) {
8382
+ throw validationError(`Invalid "only" value "${only}". Must be one of: ${validOnlyValues.join(", ")}`);
8383
+ }
8384
+ const syncTraffic = !only || only === "traffic";
8385
+ const syncAi = !only || only === "ai";
8386
+ const syncSocial = !only || only === "social";
8387
+ const syncSummary = !only;
8200
8388
  const { accessToken, propertyId } = await resolveGa4AccessToken(opts, project.name, project.canonicalDomain);
8201
- let rows;
8389
+ let rows = [];
8202
8390
  let summary;
8203
- let aiReferrals;
8391
+ let aiReferrals = [];
8392
+ let socialReferrals = [];
8204
8393
  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
- ]);
8394
+ const fetches = [fetchAggregateSummary(accessToken, propertyId, days)];
8395
+ if (syncTraffic) fetches.push(fetchTrafficByLandingPage(accessToken, propertyId, days));
8396
+ if (syncAi) fetches.push(fetchAiReferrals(accessToken, propertyId, days));
8397
+ if (syncSocial) fetches.push(fetchSocialReferrals(accessToken, propertyId, days));
8398
+ const results = await Promise.all(fetches);
8399
+ summary = results[0];
8400
+ let idx = 1;
8401
+ if (syncTraffic) {
8402
+ rows = results[idx++];
8403
+ }
8404
+ if (syncAi) {
8405
+ aiReferrals = results[idx++];
8406
+ }
8407
+ if (syncSocial) {
8408
+ socialReferrals = results[idx++];
8409
+ }
8211
8410
  } catch (e) {
8212
8411
  const msg = e instanceof Error ? e.message : String(e);
8213
8412
  gaLog("error", "sync.fetch-failed", { projectId: project.id, error: msg });
@@ -8215,14 +8414,14 @@ async function ga4Routes(app, opts) {
8215
8414
  }
8216
8415
  const now = (/* @__PURE__ */ new Date()).toISOString();
8217
8416
  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) {
8417
+ if (syncTraffic) {
8418
+ tx.delete(gaTrafficSnapshots).where(
8419
+ and6(
8420
+ eq17(gaTrafficSnapshots.projectId, project.id),
8421
+ sql3`${gaTrafficSnapshots.date} >= ${summary.periodStart}`,
8422
+ sql3`${gaTrafficSnapshots.date} <= ${summary.periodEnd}`
8423
+ )
8424
+ ).run();
8226
8425
  for (const row of rows) {
8227
8426
  tx.insert(gaTrafficSnapshots).values({
8228
8427
  id: crypto16.randomUUID(),
@@ -8236,14 +8435,14 @@ async function ga4Routes(app, opts) {
8236
8435
  }).run();
8237
8436
  }
8238
8437
  }
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) {
8438
+ if (syncAi) {
8439
+ tx.delete(gaAiReferrals).where(
8440
+ and6(
8441
+ eq17(gaAiReferrals.projectId, project.id),
8442
+ sql3`${gaAiReferrals.date} >= ${summary.periodStart}`,
8443
+ sql3`${gaAiReferrals.date} <= ${summary.periodEnd}`
8444
+ )
8445
+ ).run();
8247
8446
  for (const row of aiReferrals) {
8248
8447
  tx.insert(gaAiReferrals).values({
8249
8448
  id: crypto16.randomUUID(),
@@ -8258,31 +8457,60 @@ async function ga4Routes(app, opts) {
8258
8457
  }).run();
8259
8458
  }
8260
8459
  }
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();
8460
+ if (syncSocial) {
8461
+ tx.delete(gaSocialReferrals).where(
8462
+ and6(
8463
+ eq17(gaSocialReferrals.projectId, project.id),
8464
+ sql3`${gaSocialReferrals.date} >= ${summary.periodStart}`,
8465
+ sql3`${gaSocialReferrals.date} <= ${summary.periodEnd}`
8466
+ )
8467
+ ).run();
8468
+ for (const row of socialReferrals) {
8469
+ tx.insert(gaSocialReferrals).values({
8470
+ id: crypto16.randomUUID(),
8471
+ projectId: project.id,
8472
+ date: row.date,
8473
+ source: row.source,
8474
+ medium: row.medium,
8475
+ channelGroup: row.channelGroup,
8476
+ sessions: row.sessions,
8477
+ users: row.users,
8478
+ syncedAt: now
8479
+ }).run();
8480
+ }
8481
+ }
8482
+ if (syncSummary) {
8483
+ tx.delete(gaTrafficSummaries).where(eq17(gaTrafficSummaries.projectId, project.id)).run();
8484
+ tx.insert(gaTrafficSummaries).values({
8485
+ id: crypto16.randomUUID(),
8486
+ projectId: project.id,
8487
+ periodStart: summary.periodStart,
8488
+ periodEnd: summary.periodEnd,
8489
+ totalSessions: summary.totalSessions,
8490
+ totalOrganicSessions: summary.totalOrganicSessions,
8491
+ totalUsers: summary.totalUsers,
8492
+ syncedAt: now
8493
+ }).run();
8494
+ }
8272
8495
  });
8496
+ const syncedComponents = only ? [only, ...only !== "social" && only !== "ai" && only !== "traffic" ? [] : []] : void 0;
8273
8497
  gaLog("info", "sync.complete", {
8274
8498
  projectId: project.id,
8275
8499
  rowCount: rows.length,
8276
8500
  aiReferralCount: aiReferrals.length,
8501
+ socialReferralCount: socialReferrals.length,
8277
8502
  days,
8278
- totalUsers: summary.totalUsers
8503
+ totalUsers: summary.totalUsers,
8504
+ ...only ? { only } : {}
8279
8505
  });
8280
8506
  return {
8281
8507
  synced: true,
8282
8508
  rowCount: rows.length,
8283
8509
  aiReferralCount: aiReferrals.length,
8510
+ socialReferralCount: socialReferrals.length,
8284
8511
  days,
8285
- syncedAt: now
8512
+ syncedAt: now,
8513
+ ...syncedComponents ? { syncedComponents } : {}
8286
8514
  };
8287
8515
  });
8288
8516
  app.get("/projects/:name/ga/traffic", async (request, _reply) => {
@@ -8320,9 +8548,21 @@ async function ga4Routes(app, opts) {
8320
8548
  GROUP BY date, source, medium
8321
8549
  )`
8322
8550
  ).get();
8551
+ const socialReferrals = app.db.select({
8552
+ source: gaSocialReferrals.source,
8553
+ medium: gaSocialReferrals.medium,
8554
+ channelGroup: gaSocialReferrals.channelGroup,
8555
+ sessions: sql3`SUM(${gaSocialReferrals.sessions})`,
8556
+ users: sql3`SUM(${gaSocialReferrals.users})`
8557
+ }).from(gaSocialReferrals).where(eq17(gaSocialReferrals.projectId, project.id)).groupBy(gaSocialReferrals.source, gaSocialReferrals.medium, gaSocialReferrals.channelGroup).orderBy(sql3`SUM(${gaSocialReferrals.sessions}) DESC`).all();
8558
+ const socialTotals = app.db.select({
8559
+ sessions: sql3`SUM(${gaSocialReferrals.sessions})`,
8560
+ users: sql3`SUM(${gaSocialReferrals.users})`
8561
+ }).from(gaSocialReferrals).where(eq17(gaSocialReferrals.projectId, project.id)).get();
8323
8562
  const latestSync = app.db.select({ syncedAt: gaTrafficSummaries.syncedAt }).from(gaTrafficSummaries).where(eq17(gaTrafficSummaries.projectId, project.id)).orderBy(desc7(gaTrafficSummaries.syncedAt)).limit(1).get();
8563
+ const total = summary?.totalSessions ?? 0;
8324
8564
  return {
8325
- totalSessions: summary?.totalSessions ?? 0,
8565
+ totalSessions: total,
8326
8566
  totalOrganicSessions: summary?.totalOrganicSessions ?? 0,
8327
8567
  totalUsers: summary?.totalUsers ?? 0,
8328
8568
  topPages: rows.map((r) => ({
@@ -8340,6 +8580,18 @@ async function ga4Routes(app, opts) {
8340
8580
  })),
8341
8581
  aiSessionsDeduped: aiDeduped?.sessions ?? 0,
8342
8582
  aiUsersDeduped: aiDeduped?.users ?? 0,
8583
+ socialReferrals: socialReferrals.map((r) => ({
8584
+ source: r.source,
8585
+ medium: r.medium,
8586
+ channelGroup: r.channelGroup,
8587
+ sessions: r.sessions ?? 0,
8588
+ users: r.users ?? 0
8589
+ })),
8590
+ socialSessions: socialTotals?.sessions ?? 0,
8591
+ socialUsers: socialTotals?.users ?? 0,
8592
+ organicSharePct: total > 0 ? Math.round((summary?.totalOrganicSessions ?? 0) / total * 100) : 0,
8593
+ aiSharePct: total > 0 ? Math.round((aiDeduped?.sessions ?? 0) / total * 100) : 0,
8594
+ socialSharePct: total > 0 ? Math.round((socialTotals?.sessions ?? 0) / total * 100) : 0,
8343
8595
  lastSyncedAt: latestSync?.syncedAt ?? null
8344
8596
  };
8345
8597
  });
@@ -8356,6 +8608,146 @@ async function ga4Routes(app, opts) {
8356
8608
  }).from(gaAiReferrals).where(eq17(gaAiReferrals.projectId, project.id)).orderBy(gaAiReferrals.date).all();
8357
8609
  return rows;
8358
8610
  });
8611
+ app.get("/projects/:name/ga/social-referral-history", async (request, _reply) => {
8612
+ const project = resolveProject(app.db, request.params.name);
8613
+ requireGa4Connection(opts, project.name, project.canonicalDomain);
8614
+ const rows = app.db.select({
8615
+ date: gaSocialReferrals.date,
8616
+ source: gaSocialReferrals.source,
8617
+ medium: gaSocialReferrals.medium,
8618
+ channelGroup: gaSocialReferrals.channelGroup,
8619
+ sessions: gaSocialReferrals.sessions,
8620
+ users: gaSocialReferrals.users
8621
+ }).from(gaSocialReferrals).where(eq17(gaSocialReferrals.projectId, project.id)).orderBy(gaSocialReferrals.date).all();
8622
+ return rows;
8623
+ });
8624
+ app.get("/projects/:name/ga/social-referral-trend", async (request, _reply) => {
8625
+ const project = resolveProject(app.db, request.params.name);
8626
+ requireGa4Connection(opts, project.name, project.canonicalDomain);
8627
+ const today = /* @__PURE__ */ new Date();
8628
+ const fmt = (d) => d.toISOString().split("T")[0];
8629
+ const daysAgo2 = (n) => {
8630
+ const d = new Date(today);
8631
+ d.setDate(d.getDate() - n);
8632
+ return fmt(d);
8633
+ };
8634
+ const sumSocial = (from, to) => app.db.select({ sessions: sql3`COALESCE(SUM(${gaSocialReferrals.sessions}), 0)` }).from(gaSocialReferrals).where(and6(
8635
+ eq17(gaSocialReferrals.projectId, project.id),
8636
+ sql3`${gaSocialReferrals.date} >= ${from}`,
8637
+ sql3`${gaSocialReferrals.date} < ${to}`
8638
+ )).get();
8639
+ const current7d = sumSocial(daysAgo2(7), fmt(today));
8640
+ const prev7d = sumSocial(daysAgo2(14), daysAgo2(7));
8641
+ const current30d = sumSocial(daysAgo2(30), fmt(today));
8642
+ const prev30d = sumSocial(daysAgo2(60), daysAgo2(30));
8643
+ const pct = (cur, prev) => prev === 0 ? null : Math.round((cur - prev) / prev * 100);
8644
+ const sourceCurrent = app.db.select({
8645
+ source: gaSocialReferrals.source,
8646
+ sessions: sql3`SUM(${gaSocialReferrals.sessions})`
8647
+ }).from(gaSocialReferrals).where(and6(
8648
+ eq17(gaSocialReferrals.projectId, project.id),
8649
+ sql3`${gaSocialReferrals.date} >= ${daysAgo2(7)}`,
8650
+ sql3`${gaSocialReferrals.date} < ${fmt(today)}`
8651
+ )).groupBy(gaSocialReferrals.source).all();
8652
+ const sourcePrev = app.db.select({
8653
+ source: gaSocialReferrals.source,
8654
+ sessions: sql3`SUM(${gaSocialReferrals.sessions})`
8655
+ }).from(gaSocialReferrals).where(and6(
8656
+ eq17(gaSocialReferrals.projectId, project.id),
8657
+ sql3`${gaSocialReferrals.date} >= ${daysAgo2(14)}`,
8658
+ sql3`${gaSocialReferrals.date} < ${daysAgo2(7)}`
8659
+ )).groupBy(gaSocialReferrals.source).all();
8660
+ const prevMap = new Map(sourcePrev.map((r) => [r.source, r.sessions]));
8661
+ let biggestMover = null;
8662
+ let maxDelta = 0;
8663
+ for (const row of sourceCurrent) {
8664
+ const prev = prevMap.get(row.source) ?? 0;
8665
+ const delta = Math.abs(row.sessions - prev);
8666
+ if (delta > maxDelta) {
8667
+ maxDelta = delta;
8668
+ biggestMover = {
8669
+ source: row.source,
8670
+ sessions7d: row.sessions,
8671
+ sessionsPrev7d: prev,
8672
+ changePct: pct(row.sessions, prev) ?? (row.sessions > 0 ? 100 : 0)
8673
+ };
8674
+ }
8675
+ }
8676
+ return {
8677
+ socialSessions7d: current7d?.sessions ?? 0,
8678
+ socialSessionsPrev7d: prev7d?.sessions ?? 0,
8679
+ trend7dPct: pct(current7d?.sessions ?? 0, prev7d?.sessions ?? 0),
8680
+ socialSessions30d: current30d?.sessions ?? 0,
8681
+ socialSessionsPrev30d: prev30d?.sessions ?? 0,
8682
+ trend30dPct: pct(current30d?.sessions ?? 0, prev30d?.sessions ?? 0),
8683
+ biggestMover
8684
+ };
8685
+ });
8686
+ app.get("/projects/:name/ga/attribution-trend", async (request, _reply) => {
8687
+ const project = resolveProject(app.db, request.params.name);
8688
+ requireGa4Connection(opts, project.name, project.canonicalDomain);
8689
+ const today = /* @__PURE__ */ new Date();
8690
+ const fmt = (d) => d.toISOString().split("T")[0];
8691
+ const daysAgo2 = (n) => {
8692
+ const d = new Date(today);
8693
+ d.setDate(d.getDate() - n);
8694
+ return fmt(d);
8695
+ };
8696
+ const pct = (cur, prev) => prev === 0 ? null : Math.round((cur - prev) / prev * 100);
8697
+ 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();
8698
+ 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();
8699
+ const sumAi = (from, to) => app.db.select({ sessions: sql3`COALESCE(SUM(max_sessions), 0)` }).from(sql3`(
8700
+ SELECT date, source, medium, MAX(sessions) AS max_sessions
8701
+ FROM ga_ai_referrals
8702
+ WHERE project_id = ${project.id} AND date >= ${from} AND date < ${to}
8703
+ GROUP BY date, source, medium
8704
+ )`).get();
8705
+ 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();
8706
+ const todayStr = fmt(today);
8707
+ const buildTrend = (sum) => {
8708
+ const c7 = sum(daysAgo2(7), todayStr)?.sessions ?? 0;
8709
+ const p7 = sum(daysAgo2(14), daysAgo2(7))?.sessions ?? 0;
8710
+ const c30 = sum(daysAgo2(30), todayStr)?.sessions ?? 0;
8711
+ const p30 = sum(daysAgo2(60), daysAgo2(30))?.sessions ?? 0;
8712
+ return { sessions7d: c7, sessionsPrev7d: p7, trend7dPct: pct(c7, p7), sessions30d: c30, sessionsPrev30d: p30, trend30dPct: pct(c30, p30) };
8713
+ };
8714
+ const aiSourceCurrent = app.db.select({ source: sql3`source`, sessions: sql3`COALESCE(SUM(max_sessions), 0)` }).from(sql3`(
8715
+ SELECT date, source, medium, MAX(sessions) AS max_sessions
8716
+ FROM ga_ai_referrals
8717
+ WHERE project_id = ${project.id} AND date >= ${daysAgo2(7)} AND date < ${todayStr}
8718
+ GROUP BY date, source, medium
8719
+ )`).groupBy(sql3`source`).all();
8720
+ const aiSourcePrev = app.db.select({ source: sql3`source`, sessions: sql3`COALESCE(SUM(max_sessions), 0)` }).from(sql3`(
8721
+ SELECT date, source, medium, MAX(sessions) AS max_sessions
8722
+ FROM ga_ai_referrals
8723
+ WHERE project_id = ${project.id} AND date >= ${daysAgo2(14)} AND date < ${daysAgo2(7)}
8724
+ GROUP BY date, source, medium
8725
+ )`).groupBy(sql3`source`).all();
8726
+ const findBiggestMover = (current, prev) => {
8727
+ const prevMap = new Map(prev.map((r) => [r.source, r.sessions]));
8728
+ let mover = null;
8729
+ let maxDelta = 0;
8730
+ for (const row of current) {
8731
+ const p = prevMap.get(row.source) ?? 0;
8732
+ const delta = Math.abs(row.sessions - p);
8733
+ if (delta > maxDelta) {
8734
+ maxDelta = delta;
8735
+ mover = { source: row.source, sessions7d: row.sessions, sessionsPrev7d: p, changePct: pct(row.sessions, p) ?? (row.sessions > 0 ? 100 : 0) };
8736
+ }
8737
+ }
8738
+ return mover;
8739
+ };
8740
+ 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();
8741
+ 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();
8742
+ return {
8743
+ total: buildTrend(sumTotal),
8744
+ organic: buildTrend(sumOrganic),
8745
+ ai: buildTrend(sumAi),
8746
+ social: buildTrend(sumSocial),
8747
+ aiBiggestMover: findBiggestMover(aiSourceCurrent, aiSourcePrev),
8748
+ socialBiggestMover: findBiggestMover(socialSourceCurrent, socialSourcePrev)
8749
+ };
8750
+ });
8359
8751
  app.get("/projects/:name/ga/session-history", async (request, _reply) => {
8360
8752
  const project = resolveProject(app.db, request.params.name);
8361
8753
  requireGa4Connection(opts, project.name, project.canonicalDomain);