@ainyc/canonry 1.40.1 → 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-FOWWBLXD.js";
25
+ } from "./chunk-AATIMNOX.js";
25
26
 
26
27
  // src/config.ts
27
28
  import fs from "fs";
@@ -978,6 +979,11 @@ var scheduleUpsertRequestSchema = z10.object({
978
979
  { message: 'Exactly one of "preset" or "cron" must be provided' }
979
980
  );
980
981
 
982
+ // ../contracts/src/analytics.ts
983
+ import { z as z11 } from "zod";
984
+ var visibilityMetricModeSchema = z11.enum(["answer", "citation"]);
985
+ var VisibilityMetricModes = visibilityMetricModeSchema.enum;
986
+
981
987
  // ../contracts/src/source-categories.ts
982
988
  var SOURCE_CATEGORY_RULES = [
983
989
  // Forums
@@ -1071,62 +1077,90 @@ function categoryLabel(category) {
1071
1077
  }
1072
1078
 
1073
1079
  // ../contracts/src/ga.ts
1074
- import { z as z11 } from "zod";
1075
- var ga4ConnectionDtoSchema = z11.object({
1076
- id: z11.string(),
1077
- projectId: z11.string(),
1078
- propertyId: z11.string(),
1079
- clientEmail: z11.string(),
1080
- connected: z11.boolean(),
1081
- createdAt: z11.string(),
1082
- updatedAt: z11.string()
1080
+ import { z as z12 } from "zod";
1081
+ var ga4ConnectionDtoSchema = z12.object({
1082
+ id: z12.string(),
1083
+ projectId: z12.string(),
1084
+ propertyId: z12.string(),
1085
+ clientEmail: z12.string(),
1086
+ connected: z12.boolean(),
1087
+ createdAt: z12.string(),
1088
+ updatedAt: z12.string()
1083
1089
  });
1084
- var ga4TrafficSnapshotDtoSchema = z11.object({
1085
- date: z11.string(),
1086
- landingPage: z11.string(),
1087
- sessions: z11.number(),
1088
- organicSessions: z11.number(),
1089
- users: z11.number()
1090
+ var ga4TrafficSnapshotDtoSchema = z12.object({
1091
+ date: z12.string(),
1092
+ landingPage: z12.string(),
1093
+ sessions: z12.number(),
1094
+ organicSessions: z12.number(),
1095
+ users: z12.number()
1090
1096
  });
1091
- var ga4SourceDimensionSchema = z11.enum(["session", "first_user", "manual_utm"]);
1092
- var ga4AiReferralDtoSchema = z11.object({
1093
- source: z11.string(),
1094
- medium: z11.string(),
1095
- sessions: z11.number(),
1096
- users: z11.number(),
1097
+ var ga4SourceDimensionSchema = z12.enum(["session", "first_user", "manual_utm"]);
1098
+ var ga4AiReferralDtoSchema = z12.object({
1099
+ source: z12.string(),
1100
+ medium: z12.string(),
1101
+ sessions: z12.number(),
1102
+ users: z12.number(),
1097
1103
  sourceDimension: ga4SourceDimensionSchema
1098
1104
  });
1099
- var ga4TrafficSummaryDtoSchema = z11.object({
1100
- totalSessions: z11.number(),
1101
- totalOrganicSessions: z11.number(),
1102
- totalUsers: z11.number(),
1103
- topPages: z11.array(z11.object({
1104
- landingPage: z11.string(),
1105
- sessions: z11.number(),
1106
- organicSessions: z11.number(),
1107
- users: z11.number()
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
+ });
1113
+ var ga4TrafficSummaryDtoSchema = z12.object({
1114
+ totalSessions: z12.number(),
1115
+ totalOrganicSessions: z12.number(),
1116
+ totalUsers: z12.number(),
1117
+ topPages: z12.array(z12.object({
1118
+ landingPage: z12.string(),
1119
+ sessions: z12.number(),
1120
+ organicSessions: z12.number(),
1121
+ users: z12.number()
1108
1122
  })),
1109
- aiReferrals: z11.array(ga4AiReferralDtoSchema),
1123
+ aiReferrals: z12.array(ga4AiReferralDtoSchema),
1110
1124
  /** Deduped AI session total: MAX(sessions) per date+source+medium across attribution dimensions, then summed. */
1111
- aiSessionsDeduped: z11.number(),
1125
+ aiSessionsDeduped: z12.number(),
1112
1126
  /** Deduped AI user total: MAX(users) per date+source+medium across attribution dimensions, then summed. */
1113
- aiUsersDeduped: z11.number(),
1114
- lastSyncedAt: z11.string().nullable()
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(),
1139
+ lastSyncedAt: z12.string().nullable()
1115
1140
  });
1116
- var ga4AiReferralHistoryEntrySchema = z11.object({
1117
- date: z11.string(),
1118
- source: z11.string(),
1119
- medium: z11.string(),
1120
- sessions: z11.number(),
1121
- users: z11.number(),
1141
+ var ga4AiReferralHistoryEntrySchema = z12.object({
1142
+ date: z12.string(),
1143
+ source: z12.string(),
1144
+ medium: z12.string(),
1145
+ sessions: z12.number(),
1146
+ users: z12.number(),
1122
1147
  /** Which GA4 dimension this row came from: session (sessionSource), first_user (firstUserSource), or manual_utm (utm_source parameter) */
1123
1148
  sourceDimension: ga4SourceDimensionSchema
1124
1149
  });
1125
- var ga4SessionHistoryEntrySchema = z11.object({
1126
- date: z11.string(),
1127
- sessions: z11.number(),
1128
- organicSessions: z11.number(),
1129
- users: z11.number()
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
+ });
1159
+ var ga4SessionHistoryEntrySchema = z12.object({
1160
+ date: z12.string(),
1161
+ sessions: z12.number(),
1162
+ organicSessions: z12.number(),
1163
+ users: z12.number()
1130
1164
  });
1131
1165
 
1132
1166
  // ../contracts/src/answer-visibility.ts
@@ -2877,20 +2911,27 @@ async function analyticsRoutes(app) {
2877
2911
  return reply.send({
2878
2912
  window,
2879
2913
  buckets: [],
2880
- overall: { citationRate: 0, cited: 0, total: 0 },
2914
+ overall: { citationRate: 0, cited: 0, total: 0, answerRate: 0, answerMentionedCount: 0 },
2881
2915
  byProvider: {},
2882
2916
  trend: "stable",
2917
+ answerTrend: "stable",
2883
2918
  keywordChanges: []
2884
2919
  });
2885
2920
  }
2886
2921
  const runIds = projectRuns.map((r) => r.id);
2887
- const allSnapshots = app.db.select({
2922
+ const rawSnapshots = app.db.select({
2888
2923
  runId: querySnapshots.runId,
2889
2924
  keywordId: querySnapshots.keywordId,
2890
2925
  provider: querySnapshots.provider,
2891
2926
  citationState: querySnapshots.citationState,
2927
+ answerMentioned: querySnapshots.answerMentioned,
2928
+ answerText: querySnapshots.answerText,
2892
2929
  createdAt: querySnapshots.createdAt
2893
2930
  }).from(querySnapshots).where(inArray2(querySnapshots.runId, runIds)).all();
2931
+ const allSnapshots = rawSnapshots.map((s) => ({
2932
+ ...s,
2933
+ resolvedMentioned: resolveSnapshotAnswerMentioned(s, project)
2934
+ }));
2894
2935
  const projectKeywords = app.db.select({ id: keywords.id, createdAt: keywords.createdAt }).from(keywords).where(eq10(keywords.projectId, project.id)).all();
2895
2936
  const keywordCreatedAt = new Map(projectKeywords.map((k) => [k.id, k.createdAt]));
2896
2937
  const overall = computeProviderMetric(allSnapshots);
@@ -2904,9 +2945,10 @@ async function analyticsRoutes(app) {
2904
2945
  const spanDays = Math.max(1, Math.ceil((latest.getTime() - earliest.getTime()) / 864e5));
2905
2946
  const bucketSize = bucketSizeForSpan(spanDays);
2906
2947
  const buckets = computeBuckets(allSnapshots, projectRuns, bucketSize, keywordCreatedAt);
2907
- const trend = computeTrend(buckets);
2948
+ const trend = computeTrend(buckets, "citationRate");
2949
+ const answerTrend = computeTrend(buckets, "answerRate");
2908
2950
  const keywordChanges = computeKeywordChanges(projectKeywords, cutoff);
2909
- return reply.send({ window, buckets, overall, byProvider, trend, keywordChanges });
2951
+ return reply.send({ window, buckets, overall, byProvider, trend, answerTrend, keywordChanges });
2910
2952
  });
2911
2953
  app.get("/projects/:name/analytics/gaps", async (request, reply) => {
2912
2954
  const project = resolveProject(app.db, request.params.name);
@@ -2914,7 +2956,7 @@ async function analyticsRoutes(app) {
2914
2956
  const cutoff = windowCutoff(window);
2915
2957
  const latestRun = app.db.select().from(runs).where(eq10(runs.projectId, project.id)).orderBy(desc3(runs.createdAt)).all().find((r) => r.status === "completed" || r.status === "partial");
2916
2958
  if (!latestRun) {
2917
- return reply.send({ cited: [], gap: [], uncited: [], runId: "", window });
2959
+ return reply.send({ cited: [], gap: [], uncited: [], mentionedKeywords: [], mentionGap: [], notMentioned: [], runId: "", window });
2918
2960
  }
2919
2961
  const windowRuns = app.db.select().from(runs).where(eq10(runs.projectId, project.id)).orderBy(runs.createdAt).all().filter((r) => r.status === "completed" || r.status === "partial").filter((r) => !cutoff || r.createdAt >= cutoff);
2920
2962
  const windowRunIds = windowRuns.map((r) => r.id);
@@ -2923,25 +2965,34 @@ async function analyticsRoutes(app) {
2923
2965
  const allWindowSnaps = app.db.select({
2924
2966
  keywordId: querySnapshots.keywordId,
2925
2967
  runId: querySnapshots.runId,
2926
- citationState: querySnapshots.citationState
2968
+ citationState: querySnapshots.citationState,
2969
+ answerMentioned: querySnapshots.answerMentioned,
2970
+ answerText: querySnapshots.answerText
2927
2971
  }).from(querySnapshots).where(inArray2(querySnapshots.runId, windowRunIds)).all();
2928
2972
  for (const s of allWindowSnaps) {
2929
2973
  let entry = consistencyMap.get(s.keywordId);
2930
2974
  if (!entry) {
2931
- entry = { citedRuns: /* @__PURE__ */ new Set(), totalRuns: /* @__PURE__ */ new Set() };
2975
+ entry = { citedRuns: /* @__PURE__ */ new Set(), totalRuns: /* @__PURE__ */ new Set(), mentionedRuns: /* @__PURE__ */ new Set() };
2932
2976
  consistencyMap.set(s.keywordId, entry);
2933
2977
  }
2934
2978
  entry.totalRuns.add(s.runId);
2935
2979
  if (s.citationState === "cited") entry.citedRuns.add(s.runId);
2980
+ if (resolveSnapshotAnswerMentioned(s, project)) entry.mentionedRuns.add(s.runId);
2936
2981
  }
2937
2982
  }
2938
- const snapshots = app.db.select({
2983
+ const rawSnapshots = app.db.select({
2939
2984
  keywordId: querySnapshots.keywordId,
2940
2985
  keyword: keywords.keyword,
2941
2986
  provider: querySnapshots.provider,
2942
2987
  citationState: querySnapshots.citationState,
2988
+ answerMentioned: querySnapshots.answerMentioned,
2989
+ answerText: querySnapshots.answerText,
2943
2990
  competitorOverlap: querySnapshots.competitorOverlap
2944
2991
  }).from(querySnapshots).leftJoin(keywords, eq10(querySnapshots.keywordId, keywords.id)).where(eq10(querySnapshots.runId, latestRun.id)).all();
2992
+ const snapshots = rawSnapshots.map((s) => ({
2993
+ ...s,
2994
+ resolvedMentioned: resolveSnapshotAnswerMentioned(s, project)
2995
+ }));
2945
2996
  const byKeyword = /* @__PURE__ */ new Map();
2946
2997
  for (const s of snapshots) {
2947
2998
  const key = s.keywordId;
@@ -2952,14 +3003,24 @@ async function analyticsRoutes(app) {
2952
3003
  const cited = [];
2953
3004
  const gap = [];
2954
3005
  const uncited = [];
3006
+ const mentionedKeywords = [];
3007
+ const mentionGap = [];
3008
+ const notMentioned = [];
2955
3009
  for (const [keywordId, kwSnapshots] of byKeyword) {
2956
3010
  const keyword = kwSnapshots[0]?.keyword ?? "";
2957
3011
  const citedProviders = kwSnapshots.filter((s) => s.citationState === "cited").map((s) => s.provider);
3012
+ const mentionedProviders = kwSnapshots.filter((s) => s.resolvedMentioned).map((s) => s.provider);
2958
3013
  const competitorsCiting = /* @__PURE__ */ new Set();
2959
3014
  for (const s of kwSnapshots) {
2960
3015
  const overlap = parseJsonColumn(s.competitorOverlap, []);
2961
3016
  for (const c of overlap) competitorsCiting.add(c);
2962
3017
  }
3018
+ const cons = consistencyMap.get(keywordId);
3019
+ const consistency = {
3020
+ citedRuns: cons?.citedRuns.size ?? 0,
3021
+ totalRuns: cons?.totalRuns.size ?? 0,
3022
+ mentionedRuns: cons?.mentionedRuns.size ?? 0
3023
+ };
2963
3024
  let category;
2964
3025
  if (citedProviders.length > 0) {
2965
3026
  category = "cited";
@@ -2968,26 +3029,44 @@ async function analyticsRoutes(app) {
2968
3029
  } else {
2969
3030
  category = "uncited";
2970
3031
  }
2971
- const cons = consistencyMap.get(keywordId);
2972
- const entry = {
3032
+ const citationEntry = {
2973
3033
  keyword,
2974
3034
  keywordId,
2975
3035
  category,
2976
3036
  providers: citedProviders,
2977
3037
  competitorsCiting: [...competitorsCiting],
2978
- consistency: {
2979
- citedRuns: cons?.citedRuns.size ?? 0,
2980
- totalRuns: cons?.totalRuns.size ?? 0
2981
- }
3038
+ consistency
3039
+ };
3040
+ if (category === "cited") cited.push(citationEntry);
3041
+ else if (category === "gap") gap.push(citationEntry);
3042
+ else uncited.push(citationEntry);
3043
+ let mentionCategory;
3044
+ if (mentionedProviders.length > 0) {
3045
+ mentionCategory = "cited";
3046
+ } else if (competitorsCiting.size > 0) {
3047
+ mentionCategory = "gap";
3048
+ } else {
3049
+ mentionCategory = "uncited";
3050
+ }
3051
+ const mentionEntry = {
3052
+ keyword,
3053
+ keywordId,
3054
+ category: mentionCategory,
3055
+ providers: mentionedProviders,
3056
+ competitorsCiting: [...competitorsCiting],
3057
+ consistency
2982
3058
  };
2983
- if (category === "cited") cited.push(entry);
2984
- else if (category === "gap") gap.push(entry);
2985
- else uncited.push(entry);
3059
+ if (mentionCategory === "cited") mentionedKeywords.push(mentionEntry);
3060
+ else if (mentionCategory === "gap") mentionGap.push(mentionEntry);
3061
+ else notMentioned.push(mentionEntry);
2986
3062
  }
2987
3063
  gap.sort((a, b) => b.competitorsCiting.length - a.competitorsCiting.length);
2988
3064
  cited.sort((a, b) => a.keyword.localeCompare(b.keyword));
2989
3065
  uncited.sort((a, b) => a.keyword.localeCompare(b.keyword));
2990
- return reply.send({ cited, gap, uncited, runId: latestRun.id, window });
3066
+ mentionGap.sort((a, b) => b.competitorsCiting.length - a.competitorsCiting.length);
3067
+ mentionedKeywords.sort((a, b) => a.keyword.localeCompare(b.keyword));
3068
+ notMentioned.sort((a, b) => a.keyword.localeCompare(b.keyword));
3069
+ return reply.send({ cited, gap, uncited, mentionedKeywords, mentionGap, notMentioned, runId: latestRun.id, window });
2991
3070
  });
2992
3071
  app.get("/projects/:name/analytics/sources", async (request, reply) => {
2993
3072
  const project = resolveProject(app.db, request.params.name);
@@ -3070,10 +3149,13 @@ function bucketSizeForSpan(spanDays) {
3070
3149
  function computeProviderMetric(snapshots) {
3071
3150
  const total = snapshots.length;
3072
3151
  const cited = snapshots.filter((s) => s.citationState === "cited").length;
3152
+ const answerMentionedCount = snapshots.filter((s) => s.resolvedMentioned).length;
3073
3153
  return {
3074
3154
  citationRate: total > 0 ? Math.round(cited / total * 1e4) / 1e4 : 0,
3075
3155
  cited,
3076
- total
3156
+ total,
3157
+ answerRate: total > 0 ? Math.round(answerMentionedCount / total * 1e4) / 1e4 : 0,
3158
+ answerMentionedCount
3077
3159
  };
3078
3160
  }
3079
3161
  function computeBuckets(snapshots, projectRuns, bucketDays, keywordCreatedAt) {
@@ -3106,7 +3188,9 @@ function computeBuckets(snapshots, projectRuns, bucketDays, keywordCreatedAt) {
3106
3188
  citationRate: metric.citationRate,
3107
3189
  cited: metric.cited,
3108
3190
  total: metric.total,
3109
- keywordCount
3191
+ keywordCount,
3192
+ answerRate: metric.answerRate,
3193
+ answerMentionedCount: metric.answerMentionedCount
3110
3194
  });
3111
3195
  }
3112
3196
  start = end;
@@ -3128,14 +3212,14 @@ function computeKeywordChanges(projectKeywords, cutoff) {
3128
3212
  label: `+${count} kp`
3129
3213
  }));
3130
3214
  }
3131
- function computeTrend(buckets) {
3215
+ function computeTrend(buckets, rateKey) {
3132
3216
  const nonEmpty = buckets.filter((b) => b.total > 0);
3133
3217
  if (nonEmpty.length < 2) return "stable";
3134
3218
  const mid = Math.floor(nonEmpty.length / 2);
3135
3219
  const firstHalf = nonEmpty.slice(0, mid);
3136
3220
  const secondHalf = nonEmpty.slice(mid);
3137
- const avgFirst = firstHalf.reduce((s, b) => s + b.citationRate, 0) / firstHalf.length;
3138
- const avgSecond = secondHalf.reduce((s, b) => s + b.citationRate, 0) / secondHalf.length;
3221
+ const avgFirst = firstHalf.reduce((s, b) => s + b[rateKey], 0) / firstHalf.length;
3222
+ const avgSecond = secondHalf.reduce((s, b) => s + b[rateKey], 0) / secondHalf.length;
3139
3223
  const diff = avgSecond - avgFirst;
3140
3224
  if (diff > 0.05) return "improving";
3141
3225
  if (diff < -0.05) return "declining";
@@ -5241,6 +5325,42 @@ var routeCatalog = [
5241
5325
  404: { description: "Project not found." }
5242
5326
  }
5243
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
+ },
5244
5364
  {
5245
5365
  method: "get",
5246
5366
  path: "/api/v1/projects/{name}/ga/session-history",
@@ -5836,6 +5956,7 @@ var GSC_DATA_LAG_DAYS = 3;
5836
5956
  var INDEXING_API_BASE = "https://indexing.googleapis.com/v3";
5837
5957
  var INDEXING_API_DAILY_LIMIT = 200;
5838
5958
  var GOOGLE_REQUEST_TIMEOUT_MS = 3e4;
5959
+ var GSC_MAX_PAGES = 40;
5839
5960
 
5840
5961
  // ../integration-google/src/types.ts
5841
5962
  var GoogleAuthError = class extends Error {
@@ -5934,7 +6055,15 @@ async function exchangeCode(clientId, clientSecret, code, redirectUri) {
5934
6055
  });
5935
6056
  if (!res.ok) {
5936
6057
  const body = await res.text();
5937
- 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}`);
5938
6067
  }
5939
6068
  return await res.json();
5940
6069
  }
@@ -5955,7 +6084,15 @@ async function refreshAccessToken(clientId, clientSecret, currentRefreshToken) {
5955
6084
  });
5956
6085
  if (!res.ok) {
5957
6086
  const body = await res.text();
5958
- 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}`);
5959
6096
  }
5960
6097
  return await res.json();
5961
6098
  }
@@ -6002,6 +6139,18 @@ function validateUrl(urlParam) {
6002
6139
  throw new GoogleApiError("URL must be a valid URL", 400);
6003
6140
  }
6004
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
+ }
6005
6154
  function gscClientLog(level, action, ctx) {
6006
6155
  const entry = { ts: (/* @__PURE__ */ new Date()).toISOString(), level, module: "GscClient", action, ...ctx };
6007
6156
  const stream = level === "error" ? process.stderr : process.stdout;
@@ -6020,19 +6169,18 @@ async function gscFetch(accessToken, url, opts) {
6020
6169
  signal: AbortSignal.timeout(GOOGLE_REQUEST_TIMEOUT_MS)
6021
6170
  });
6022
6171
  if (res.status === 401) {
6023
- const body = await res.text().catch(() => "");
6024
- gscClientLog("error", "http.auth-expired", { url, method, httpStatus: 401, responseBody: body });
6172
+ gscClientLog("error", "http.auth-expired", { url, method, httpStatus: 401 });
6025
6173
  throw new GoogleApiError("Access token expired or revoked", 401);
6026
6174
  }
6027
6175
  if (res.status === 429) {
6028
- const body = await res.text().catch(() => "");
6029
- gscClientLog("error", "http.rate-limited", { url, method, httpStatus: 429, responseBody: body });
6176
+ gscClientLog("error", "http.rate-limited", { url, method, httpStatus: 429 });
6030
6177
  throw new GoogleApiError("Google API rate limit exceeded", 429);
6031
6178
  }
6032
6179
  if (!res.ok) {
6033
6180
  const body = await res.text();
6034
- gscClientLog("error", "http.error", { url, method, httpStatus: res.status, responseBody: body });
6035
- throw new GoogleApiError(`GSC API error (${res.status}): ${body}`, res.status);
6181
+ gscClientLog("error", "http.error", { url, method, httpStatus: 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);
6036
6184
  }
6037
6185
  return await res.json();
6038
6186
  }
@@ -6057,10 +6205,16 @@ async function listSitemaps(accessToken, siteUrl) {
6057
6205
  async function fetchSearchAnalytics(accessToken, siteUrl, opts) {
6058
6206
  validateAccessToken(accessToken);
6059
6207
  validateSiteUrl(siteUrl);
6208
+ validateDate(opts.startDate, "startDate");
6209
+ validateDate(opts.endDate, "endDate");
6060
6210
  const allRows = [];
6061
6211
  let startRow = 0;
6062
6212
  const dimensions = opts.dimensions ?? ["query", "page", "country", "device", "date"];
6063
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
+ }
6064
6218
  const requestBody = {
6065
6219
  startDate: opts.startDate,
6066
6220
  endDate: opts.endDate,
@@ -6130,6 +6284,7 @@ var GOOGLE_TOKEN_URL2 = "https://oauth2.googleapis.com/token";
6130
6284
  var GA4_DEFAULT_SYNC_DAYS = 30;
6131
6285
  var GA4_MAX_SYNC_DAYS = 90;
6132
6286
  var GA4_REQUEST_TIMEOUT_MS = 3e4;
6287
+ var GA4_MAX_PAGES = 50;
6133
6288
 
6134
6289
  // ../integration-google-analytics/src/types.ts
6135
6290
  var GA4ApiError = class extends Error {
@@ -6214,8 +6369,9 @@ async function getAccessToken(clientEmail, privateKey) {
6214
6369
  });
6215
6370
  if (!res.ok) {
6216
6371
  const body = await res.text().catch(() => "");
6217
- ga4Log("error", "token.failed", { httpStatus: res.status, responseBody: body });
6218
- throw new GA4ApiError(`Failed to get access token: ${body}`, res.status);
6372
+ ga4Log("error", "token.failed", { httpStatus: 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);
6219
6375
  }
6220
6376
  const data = await res.json();
6221
6377
  return data.access_token;
@@ -6244,7 +6400,7 @@ async function runReport(accessToken, propertyId, request) {
6244
6400
  } catch {
6245
6401
  if (body.length < 200) detail = ` ${body}`;
6246
6402
  }
6247
- ga4Log("error", "report.auth-failed", { propertyId, httpStatus: res.status, responseBody: body });
6403
+ ga4Log("error", "report.auth-failed", { propertyId, httpStatus: res.status });
6248
6404
  throw new GA4ApiError(
6249
6405
  `GA4 API authentication failed \u2014 check service account permissions.${detail}`,
6250
6406
  res.status
@@ -6256,8 +6412,9 @@ async function runReport(accessToken, propertyId, request) {
6256
6412
  }
6257
6413
  if (!res.ok) {
6258
6414
  const body = await res.text();
6259
- ga4Log("error", "report.error", { propertyId, httpStatus: res.status, responseBody: body });
6260
- throw new GA4ApiError(`GA4 API error (${res.status}): ${body}`, res.status);
6415
+ ga4Log("error", "report.error", { propertyId, httpStatus: 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);
6261
6418
  }
6262
6419
  return await res.json();
6263
6420
  }
@@ -6287,7 +6444,8 @@ async function batchRunReports(accessToken, propertyId, requests) {
6287
6444
  if (!res.ok) {
6288
6445
  const body = await res.text();
6289
6446
  ga4Log("error", "batch-report.error", { propertyId, httpStatus: res.status });
6290
- 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);
6291
6449
  }
6292
6450
  const data = await res.json();
6293
6451
  return data.reports;
@@ -6318,7 +6476,9 @@ async function fetchTrafficByLandingPage(accessToken, propertyId, days) {
6318
6476
  const PAGE_SIZE = 1e4;
6319
6477
  const rows = [];
6320
6478
  let offset = 0;
6321
- while (true) {
6479
+ let pageCount = 0;
6480
+ while (pageCount < GA4_MAX_PAGES) {
6481
+ pageCount++;
6322
6482
  const request = {
6323
6483
  dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
6324
6484
  dimensions: [
@@ -6348,7 +6508,9 @@ async function fetchTrafficByLandingPage(accessToken, propertyId, days) {
6348
6508
  }
6349
6509
  const organicMap = /* @__PURE__ */ new Map();
6350
6510
  let organicOffset = 0;
6351
- while (true) {
6511
+ let organicPageCount = 0;
6512
+ while (organicPageCount < GA4_MAX_PAGES) {
6513
+ organicPageCount++;
6352
6514
  const organicRequest = {
6353
6515
  dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
6354
6516
  dimensions: [{ name: "date" }, { name: "landingPagePlusQueryString" }],
@@ -6462,7 +6624,9 @@ async function fetchAiReferrals(accessToken, propertyId, days) {
6462
6624
  ];
6463
6625
  for (const [sourceDim, mediumDim, dimLabel] of dimensionPairs) {
6464
6626
  let offset = 0;
6465
- while (true) {
6627
+ let aiRefPageCount = 0;
6628
+ while (aiRefPageCount < GA4_MAX_PAGES) {
6629
+ aiRefPageCount++;
6466
6630
  const request = {
6467
6631
  dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
6468
6632
  dimensions: [
@@ -6525,6 +6689,66 @@ async function fetchAiReferrals(accessToken, propertyId, days) {
6525
6689
  ga4Log("info", "fetch-ai-referrals.done", { propertyId, rowCount: dedupedRows.length });
6526
6690
  return dedupedRows;
6527
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
+ }
6528
6752
 
6529
6753
  // ../api-routes/src/google.ts
6530
6754
  function signState(payload, secret) {
@@ -7317,19 +7541,18 @@ async function bingFetch(apiKey, endpoint, opts) {
7317
7541
  signal: AbortSignal.timeout(BING_REQUEST_TIMEOUT_MS)
7318
7542
  });
7319
7543
  if (res.status === 401 || res.status === 403) {
7320
- const body = await res.text().catch(() => "");
7321
- bingClientLog("error", "http.auth-failed", { endpoint, method, httpStatus: res.status, responseBody: body });
7544
+ bingClientLog("error", "http.auth-failed", { endpoint, method, httpStatus: res.status });
7322
7545
  throw new BingApiError("Bing API key is invalid or unauthorized", res.status);
7323
7546
  }
7324
7547
  if (res.status === 429) {
7325
- const body = await res.text().catch(() => "");
7326
- bingClientLog("error", "http.rate-limited", { endpoint, method, httpStatus: 429, responseBody: body });
7548
+ bingClientLog("error", "http.rate-limited", { endpoint, method, httpStatus: 429 });
7327
7549
  throw new BingApiError("Bing API rate limit exceeded", 429);
7328
7550
  }
7329
7551
  if (!res.ok) {
7330
7552
  const body = await res.text();
7331
- bingClientLog("error", "http.error", { endpoint, method, httpStatus: res.status, responseBody: body });
7332
- throw new BingApiError(`Bing API error (${res.status}): ${body}`, res.status);
7553
+ bingClientLog("error", "http.error", { endpoint, method, httpStatus: 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);
7333
7556
  }
7334
7557
  const text = await res.text();
7335
7558
  if (!text || text.trim() === "") {
@@ -7371,6 +7594,12 @@ async function submitUrlBatch(apiKey, siteUrl, urls) {
7371
7594
  validateApiKey(apiKey);
7372
7595
  validateSiteUrl2(siteUrl);
7373
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
+ }
7374
7603
  for (let i = 0; i < urls.length; i += BING_SUBMIT_URL_BATCH_LIMIT) {
7375
7604
  const batch = urls.slice(i, i + BING_SUBMIT_URL_BATCH_LIMIT);
7376
7605
  await bingFetch(apiKey, "SubmitUrlbatch", {
@@ -8112,6 +8341,7 @@ async function ga4Routes(app, opts) {
8112
8341
  app.db.delete(gaTrafficSnapshots).where(eq17(gaTrafficSnapshots.projectId, project.id)).run();
8113
8342
  app.db.delete(gaTrafficSummaries).where(eq17(gaTrafficSummaries.projectId, project.id)).run();
8114
8343
  app.db.delete(gaAiReferrals).where(eq17(gaAiReferrals.projectId, project.id)).run();
8344
+ app.db.delete(gaSocialReferrals).where(eq17(gaSocialReferrals.projectId, project.id)).run();
8115
8345
  const propertyId = saConn?.propertyId ?? oauthConn?.propertyId ?? null;
8116
8346
  opts.ga4CredentialStore?.deleteConnection(project.name);
8117
8347
  opts.googleConnectionStore?.deleteConnection(project.canonicalDomain, "ga4");
@@ -8146,17 +8376,37 @@ async function ga4Routes(app, opts) {
8146
8376
  app.post("/projects/:name/ga/sync", async (request, _reply) => {
8147
8377
  const project = resolveProject(app.db, request.params.name);
8148
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;
8149
8388
  const { accessToken, propertyId } = await resolveGa4AccessToken(opts, project.name, project.canonicalDomain);
8150
- let rows;
8389
+ let rows = [];
8151
8390
  let summary;
8152
- let aiReferrals;
8391
+ let aiReferrals = [];
8392
+ let socialReferrals = [];
8153
8393
  try {
8154
- ;
8155
- [rows, summary, aiReferrals] = await Promise.all([
8156
- fetchTrafficByLandingPage(accessToken, propertyId, days),
8157
- fetchAggregateSummary(accessToken, propertyId, days),
8158
- fetchAiReferrals(accessToken, propertyId, days)
8159
- ]);
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
+ }
8160
8410
  } catch (e) {
8161
8411
  const msg = e instanceof Error ? e.message : String(e);
8162
8412
  gaLog("error", "sync.fetch-failed", { projectId: project.id, error: msg });
@@ -8164,14 +8414,14 @@ async function ga4Routes(app, opts) {
8164
8414
  }
8165
8415
  const now = (/* @__PURE__ */ new Date()).toISOString();
8166
8416
  app.db.transaction((tx) => {
8167
- tx.delete(gaTrafficSnapshots).where(
8168
- and6(
8169
- eq17(gaTrafficSnapshots.projectId, project.id),
8170
- sql3`${gaTrafficSnapshots.date} >= ${summary.periodStart}`,
8171
- sql3`${gaTrafficSnapshots.date} <= ${summary.periodEnd}`
8172
- )
8173
- ).run();
8174
- 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();
8175
8425
  for (const row of rows) {
8176
8426
  tx.insert(gaTrafficSnapshots).values({
8177
8427
  id: crypto16.randomUUID(),
@@ -8185,14 +8435,14 @@ async function ga4Routes(app, opts) {
8185
8435
  }).run();
8186
8436
  }
8187
8437
  }
8188
- tx.delete(gaAiReferrals).where(
8189
- and6(
8190
- eq17(gaAiReferrals.projectId, project.id),
8191
- sql3`${gaAiReferrals.date} >= ${summary.periodStart}`,
8192
- sql3`${gaAiReferrals.date} <= ${summary.periodEnd}`
8193
- )
8194
- ).run();
8195
- 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();
8196
8446
  for (const row of aiReferrals) {
8197
8447
  tx.insert(gaAiReferrals).values({
8198
8448
  id: crypto16.randomUUID(),
@@ -8207,31 +8457,60 @@ async function ga4Routes(app, opts) {
8207
8457
  }).run();
8208
8458
  }
8209
8459
  }
8210
- tx.delete(gaTrafficSummaries).where(eq17(gaTrafficSummaries.projectId, project.id)).run();
8211
- tx.insert(gaTrafficSummaries).values({
8212
- id: crypto16.randomUUID(),
8213
- projectId: project.id,
8214
- periodStart: summary.periodStart,
8215
- periodEnd: summary.periodEnd,
8216
- totalSessions: summary.totalSessions,
8217
- totalOrganicSessions: summary.totalOrganicSessions,
8218
- totalUsers: summary.totalUsers,
8219
- syncedAt: now
8220
- }).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
+ }
8221
8495
  });
8496
+ const syncedComponents = only ? [only, ...only !== "social" && only !== "ai" && only !== "traffic" ? [] : []] : void 0;
8222
8497
  gaLog("info", "sync.complete", {
8223
8498
  projectId: project.id,
8224
8499
  rowCount: rows.length,
8225
8500
  aiReferralCount: aiReferrals.length,
8501
+ socialReferralCount: socialReferrals.length,
8226
8502
  days,
8227
- totalUsers: summary.totalUsers
8503
+ totalUsers: summary.totalUsers,
8504
+ ...only ? { only } : {}
8228
8505
  });
8229
8506
  return {
8230
8507
  synced: true,
8231
8508
  rowCount: rows.length,
8232
8509
  aiReferralCount: aiReferrals.length,
8510
+ socialReferralCount: socialReferrals.length,
8233
8511
  days,
8234
- syncedAt: now
8512
+ syncedAt: now,
8513
+ ...syncedComponents ? { syncedComponents } : {}
8235
8514
  };
8236
8515
  });
8237
8516
  app.get("/projects/:name/ga/traffic", async (request, _reply) => {
@@ -8269,9 +8548,21 @@ async function ga4Routes(app, opts) {
8269
8548
  GROUP BY date, source, medium
8270
8549
  )`
8271
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();
8272
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;
8273
8564
  return {
8274
- totalSessions: summary?.totalSessions ?? 0,
8565
+ totalSessions: total,
8275
8566
  totalOrganicSessions: summary?.totalOrganicSessions ?? 0,
8276
8567
  totalUsers: summary?.totalUsers ?? 0,
8277
8568
  topPages: rows.map((r) => ({
@@ -8289,6 +8580,18 @@ async function ga4Routes(app, opts) {
8289
8580
  })),
8290
8581
  aiSessionsDeduped: aiDeduped?.sessions ?? 0,
8291
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,
8292
8595
  lastSyncedAt: latestSync?.syncedAt ?? null
8293
8596
  };
8294
8597
  });
@@ -8305,6 +8608,146 @@ async function ga4Routes(app, opts) {
8305
8608
  }).from(gaAiReferrals).where(eq17(gaAiReferrals.projectId, project.id)).orderBy(gaAiReferrals.date).all();
8306
8609
  return rows;
8307
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
+ });
8308
8751
  app.get("/projects/:name/ga/session-history", async (request, _reply) => {
8309
8752
  const project = resolveProject(app.db, request.params.name);
8310
8753
  requireGa4Connection(opts, project.name, project.canonicalDomain);
@@ -10137,6 +10580,15 @@ async function apiRoutes(app, opts) {
10137
10580
  import { GoogleGenAI } from "@google/genai";
10138
10581
 
10139
10582
  // ../provider-gemini/src/utils.ts
10583
+ function isRetryableError(err) {
10584
+ if (err != null && typeof err === "object" && "status" in err) {
10585
+ const status = err.status;
10586
+ if (typeof status === "number") {
10587
+ return status >= 500 || status === 429;
10588
+ }
10589
+ }
10590
+ return true;
10591
+ }
10140
10592
  async function withRetry(fn, options = {}) {
10141
10593
  const { maxRetries = 3, initialDelay = 1e3 } = options;
10142
10594
  let lastError;
@@ -10145,10 +10597,12 @@ async function withRetry(fn, options = {}) {
10145
10597
  return await fn();
10146
10598
  } catch (err) {
10147
10599
  lastError = err;
10148
- if (attempt < maxRetries) {
10600
+ if (attempt < maxRetries && isRetryableError(err)) {
10149
10601
  const delay = initialDelay * Math.pow(2, attempt);
10150
10602
  console.warn(`[provider] Attempt ${attempt + 1} failed, retrying in ${delay}ms...`, err instanceof Error ? err.message : String(err));
10151
10603
  await new Promise((resolve) => setTimeout(resolve, delay));
10604
+ } else {
10605
+ throw err;
10152
10606
  }
10153
10607
  }
10154
10608
  }
@@ -10510,6 +10964,15 @@ var geminiAdapter = {
10510
10964
  import OpenAI from "openai";
10511
10965
 
10512
10966
  // ../provider-openai/src/utils.ts
10967
+ function isRetryableError2(err) {
10968
+ if (err != null && typeof err === "object" && "status" in err) {
10969
+ const status = err.status;
10970
+ if (typeof status === "number") {
10971
+ return status >= 500 || status === 429;
10972
+ }
10973
+ }
10974
+ return true;
10975
+ }
10513
10976
  async function withRetry2(fn, options = {}) {
10514
10977
  const { maxRetries = 3, initialDelay = 1e3 } = options;
10515
10978
  let lastError;
@@ -10518,10 +10981,12 @@ async function withRetry2(fn, options = {}) {
10518
10981
  return await fn();
10519
10982
  } catch (err) {
10520
10983
  lastError = err;
10521
- if (attempt < maxRetries) {
10984
+ if (attempt < maxRetries && isRetryableError2(err)) {
10522
10985
  const delay = initialDelay * Math.pow(2, attempt);
10523
10986
  console.warn(`[provider] Attempt ${attempt + 1} failed, retrying in ${delay}ms...`, err instanceof Error ? err.message : String(err));
10524
10987
  await new Promise((resolve) => setTimeout(resolve, delay));
10988
+ } else {
10989
+ throw err;
10525
10990
  }
10526
10991
  }
10527
10992
  }
@@ -10843,6 +11308,15 @@ var openaiAdapter = {
10843
11308
  import Anthropic from "@anthropic-ai/sdk";
10844
11309
 
10845
11310
  // ../provider-claude/src/utils.ts
11311
+ function isRetryableError3(err) {
11312
+ if (err != null && typeof err === "object" && "status" in err) {
11313
+ const status = err.status;
11314
+ if (typeof status === "number") {
11315
+ return status >= 500 || status === 429;
11316
+ }
11317
+ }
11318
+ return true;
11319
+ }
10846
11320
  async function withRetry3(fn, options = {}) {
10847
11321
  const { maxRetries = 3, initialDelay = 1e3 } = options;
10848
11322
  let lastError;
@@ -10851,10 +11325,12 @@ async function withRetry3(fn, options = {}) {
10851
11325
  return await fn();
10852
11326
  } catch (err) {
10853
11327
  lastError = err;
10854
- if (attempt < maxRetries) {
11328
+ if (attempt < maxRetries && isRetryableError3(err)) {
10855
11329
  const delay = initialDelay * Math.pow(2, attempt);
10856
11330
  console.warn(`[provider] Attempt ${attempt + 1} failed, retrying in ${delay}ms...`, err instanceof Error ? err.message : String(err));
10857
11331
  await new Promise((resolve) => setTimeout(resolve, delay));
11332
+ } else {
11333
+ throw err;
10858
11334
  }
10859
11335
  }
10860
11336
  }
@@ -11199,6 +11675,15 @@ var claudeAdapter = {
11199
11675
  import OpenAI2 from "openai";
11200
11676
 
11201
11677
  // ../provider-local/src/utils.ts
11678
+ function isRetryableError4(err) {
11679
+ if (err != null && typeof err === "object" && "status" in err) {
11680
+ const status = err.status;
11681
+ if (typeof status === "number") {
11682
+ return status >= 500 || status === 429;
11683
+ }
11684
+ }
11685
+ return true;
11686
+ }
11202
11687
  async function withRetry4(fn, options = {}) {
11203
11688
  const { maxRetries = 3, initialDelay = 1e3 } = options;
11204
11689
  let lastError;
@@ -11207,10 +11692,12 @@ async function withRetry4(fn, options = {}) {
11207
11692
  return await fn();
11208
11693
  } catch (err) {
11209
11694
  lastError = err;
11210
- if (attempt < maxRetries) {
11695
+ if (attempt < maxRetries && isRetryableError4(err)) {
11211
11696
  const delay = initialDelay * Math.pow(2, attempt);
11212
11697
  console.warn(`[provider] Attempt ${attempt + 1} failed, retrying in ${delay}ms...`, err instanceof Error ? err.message : String(err));
11213
11698
  await new Promise((resolve) => setTimeout(resolve, delay));
11699
+ } else {
11700
+ throw err;
11214
11701
  }
11215
11702
  }
11216
11703
  }
@@ -11994,6 +12481,15 @@ var cdpChatgptAdapter = {
11994
12481
  import OpenAI3 from "openai";
11995
12482
 
11996
12483
  // ../provider-perplexity/src/utils.ts
12484
+ function isRetryableError5(err) {
12485
+ if (err != null && typeof err === "object" && "status" in err) {
12486
+ const status = err.status;
12487
+ if (typeof status === "number") {
12488
+ return status >= 500 || status === 429;
12489
+ }
12490
+ }
12491
+ return true;
12492
+ }
11997
12493
  async function withRetry5(fn, options = {}) {
11998
12494
  const { maxRetries = 3, initialDelay = 1e3 } = options;
11999
12495
  let lastError;
@@ -12002,10 +12498,12 @@ async function withRetry5(fn, options = {}) {
12002
12498
  return await fn();
12003
12499
  } catch (err) {
12004
12500
  lastError = err;
12005
- if (attempt < maxRetries) {
12501
+ if (attempt < maxRetries && isRetryableError5(err)) {
12006
12502
  const delay = initialDelay * Math.pow(2, attempt);
12007
12503
  console.warn(`[provider] Attempt ${attempt + 1} failed, retrying in ${delay}ms...`, err instanceof Error ? err.message : String(err));
12008
12504
  await new Promise((resolve) => setTimeout(resolve, delay));
12505
+ } else {
12506
+ throw err;
12009
12507
  }
12010
12508
  }
12011
12509
  }