@ainyc/canonry 1.30.0 → 1.32.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.
@@ -494,6 +494,9 @@ function unsupportedKind(kind) {
494
494
  function notImplemented(message) {
495
495
  return new AppError("NOT_IMPLEMENTED", message, 501);
496
496
  }
497
+ function deliveryFailed(message) {
498
+ return new AppError("DELIVERY_FAILED", message, 502);
499
+ }
497
500
 
498
501
  // ../contracts/src/google.ts
499
502
  import { z as z5 } from "zod";
@@ -781,6 +784,7 @@ var runStatusSchema = z8.enum(["queued", "running", "completed", "partial", "fai
781
784
  var runKindSchema = z8.enum(["answer-visibility", "site-audit", "gsc-sync", "inspect-sitemap"]);
782
785
  var runTriggerSchema = z8.enum(["manual", "scheduled", "config-apply"]);
783
786
  var citationStateSchema = z8.enum(["cited", "not-cited"]);
787
+ var visibilityStateSchema = z8.enum(["visible", "not-visible"]);
784
788
  var computedTransitionSchema = z8.enum(["new", "cited", "lost", "emerging", "not-cited"]);
785
789
  var runDtoSchema = z8.object({
786
790
  id: z8.string(),
@@ -805,6 +809,8 @@ var querySnapshotDtoSchema = z8.object({
805
809
  keyword: z8.string().optional(),
806
810
  provider: providerNameSchema,
807
811
  citationState: citationStateSchema,
812
+ answerMentioned: z8.boolean().optional(),
813
+ visibilityState: visibilityStateSchema.optional(),
808
814
  transition: computedTransitionSchema.optional(),
809
815
  answerText: z8.string().nullable().optional(),
810
816
  citedDomains: z8.array(z8.string()).default([]),
@@ -1068,6 +1074,90 @@ var ga4TrafficSummaryDtoSchema = z11.object({
1068
1074
  lastSyncedAt: z11.string().nullable()
1069
1075
  });
1070
1076
 
1077
+ // ../contracts/src/answer-visibility.ts
1078
+ var GENERIC_TOKENS = /* @__PURE__ */ new Set([
1079
+ "agency",
1080
+ "app",
1081
+ "company",
1082
+ "corp",
1083
+ "group",
1084
+ "health",
1085
+ "inc",
1086
+ "llc",
1087
+ "online",
1088
+ "platform",
1089
+ "services",
1090
+ "site",
1091
+ "solutions",
1092
+ "software",
1093
+ "systems",
1094
+ "tech"
1095
+ ]);
1096
+ function determineAnswerMentioned(answerText, displayName, domains) {
1097
+ if (!answerText) return false;
1098
+ const lowerAnswer = answerText.toLowerCase();
1099
+ for (const domain of domains) {
1100
+ const normalizedDomain = normalizeProjectDomain(domain);
1101
+ if (!normalizedDomain || !normalizedDomain.includes(".")) continue;
1102
+ if (domainMentioned(lowerAnswer, normalizedDomain)) return true;
1103
+ }
1104
+ const normalizedDisplayName = normalizeText(displayName);
1105
+ if (normalizedDisplayName && normalizeText(answerText).includes(normalizedDisplayName)) {
1106
+ return true;
1107
+ }
1108
+ const tokens = collectDistinctiveTokens(displayName, domains);
1109
+ if (tokens.length === 0) return false;
1110
+ let matches = 0;
1111
+ for (const token of tokens) {
1112
+ if (new RegExp(`\\b${escapeRegExp(token)}\\b`).test(lowerAnswer)) {
1113
+ matches++;
1114
+ }
1115
+ }
1116
+ if (tokens.length === 1) {
1117
+ return matches >= 1;
1118
+ }
1119
+ return matches >= Math.min(2, tokens.length);
1120
+ }
1121
+ function visibilityStateFromAnswerMentioned(answerMentioned) {
1122
+ return answerMentioned ? "visible" : "not-visible";
1123
+ }
1124
+ function domainMentioned(lowerAnswer, normalizedDomain) {
1125
+ const escapedDomain = escapeRegExp(normalizedDomain.toLowerCase());
1126
+ const patterns = [
1127
+ new RegExp(`(^|[^a-z0-9-])${escapedDomain}($|[^a-z0-9-])`),
1128
+ new RegExp(`https?://(?:www\\.)?${escapedDomain}(?:[/:?#]|$)`),
1129
+ new RegExp(`www\\.${escapedDomain}(?:[/:?#]|$)`)
1130
+ ];
1131
+ return patterns.some((pattern) => pattern.test(lowerAnswer));
1132
+ }
1133
+ function collectDistinctiveTokens(displayName, domains) {
1134
+ const tokens = /* @__PURE__ */ new Set();
1135
+ for (const token of extractDistinctiveTokens(displayName)) {
1136
+ tokens.add(token);
1137
+ }
1138
+ for (const domain of domains) {
1139
+ const hostname = normalizeProjectDomain(domain).split("/")[0] ?? "";
1140
+ for (const label of hostname.split(".").filter(Boolean)) {
1141
+ const token = label.replace(/[^a-z0-9]/gi, "").toLowerCase();
1142
+ if (isDistinctiveToken(token)) tokens.add(token);
1143
+ }
1144
+ }
1145
+ return [...tokens];
1146
+ }
1147
+ function extractDistinctiveTokens(value) {
1148
+ return normalizeText(value).split(" ").filter(isDistinctiveToken);
1149
+ }
1150
+ function isDistinctiveToken(token) {
1151
+ if (token.length < 4) return false;
1152
+ return !GENERIC_TOKENS.has(token);
1153
+ }
1154
+ function normalizeText(value) {
1155
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
1156
+ }
1157
+ function escapeRegExp(value) {
1158
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1159
+ }
1160
+
1071
1161
  // ../api-routes/src/auth.ts
1072
1162
  import crypto2 from "crypto";
1073
1163
  import { eq } from "drizzle-orm";
@@ -1162,6 +1252,7 @@ var querySnapshots = sqliteTable("query_snapshots", {
1162
1252
  provider: text("provider").notNull().default("gemini"),
1163
1253
  model: text("model"),
1164
1254
  citationState: text("citation_state").notNull(),
1255
+ answerMentioned: integer("answer_mentioned", { mode: "boolean" }),
1165
1256
  answerText: text("answer_text"),
1166
1257
  citedDomains: text("cited_domains").notNull().default("[]"),
1167
1258
  competitorOverlap: text("competitor_overlap").notNull().default("[]"),
@@ -1406,6 +1497,16 @@ function createClient(databasePath) {
1406
1497
  return drizzle(sqlite, { schema: schema_exports });
1407
1498
  }
1408
1499
 
1500
+ // ../db/src/json.ts
1501
+ function parseJsonColumn(value, fallback) {
1502
+ if (value == null || value === "") return fallback;
1503
+ try {
1504
+ return JSON.parse(value);
1505
+ } catch {
1506
+ return fallback;
1507
+ }
1508
+ }
1509
+
1409
1510
  // ../db/src/migrate.ts
1410
1511
  import { sql } from "drizzle-orm";
1411
1512
  var MIGRATION_SQL = `
@@ -1718,7 +1819,9 @@ var MIGRATIONS = [
1718
1819
  )`,
1719
1820
  `CREATE INDEX IF NOT EXISTS idx_ga_ai_ref_project_date ON ga_ai_referrals(project_id, date)`,
1720
1821
  `CREATE INDEX IF NOT EXISTS idx_ga_ai_ref_source ON ga_ai_referrals(source)`,
1721
- `CREATE UNIQUE INDEX IF NOT EXISTS idx_ga_ai_ref_unique ON ga_ai_referrals(project_id, date, source, medium)`
1822
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_ga_ai_ref_unique ON ga_ai_referrals(project_id, date, source, medium)`,
1823
+ // v18: Answer-level visibility derived from answer text
1824
+ `ALTER TABLE query_snapshots ADD COLUMN answer_mentioned INTEGER`
1722
1825
  ];
1723
1826
  function migrate(db) {
1724
1827
  const statements = MIGRATION_SQL.split(";").map((s) => s.trim()).filter((s) => s.length > 0);
@@ -1806,7 +1909,7 @@ import { eq as eq3 } from "drizzle-orm";
1806
1909
 
1807
1910
  // ../api-routes/src/helpers.ts
1808
1911
  import crypto3 from "crypto";
1809
- import { eq as eq2, and } from "drizzle-orm";
1912
+ import { eq as eq2, sql as sql2 } from "drizzle-orm";
1810
1913
  function resolveProject(db, name) {
1811
1914
  const project = db.select().from(projects).where(eq2(projects.name, name)).get();
1812
1915
  if (!project) {
@@ -1827,6 +1930,23 @@ function writeAuditLog(db, entry) {
1827
1930
  createdAt: now
1828
1931
  }).run();
1829
1932
  }
1933
+ function resolveSnapshotAnswerMentioned(snapshot, project) {
1934
+ if (typeof snapshot.answerMentioned === "boolean") {
1935
+ return snapshot.answerMentioned;
1936
+ }
1937
+ return determineAnswerMentioned(snapshot.answerText, project.displayName, effectiveDomains({
1938
+ canonicalDomain: project.canonicalDomain,
1939
+ ownedDomains: normalizeOwnedDomains(project.ownedDomains)
1940
+ }));
1941
+ }
1942
+ function resolveSnapshotVisibilityState(snapshot, project) {
1943
+ return visibilityStateFromAnswerMentioned(resolveSnapshotAnswerMentioned(snapshot, project));
1944
+ }
1945
+ function normalizeOwnedDomains(value) {
1946
+ if (Array.isArray(value)) return value.filter((item) => typeof item === "string");
1947
+ const parsed = parseJsonColumn(typeof value === "string" ? value : null, []);
1948
+ return parsed.filter((item) => typeof item === "string");
1949
+ }
1830
1950
 
1831
1951
  // ../api-routes/src/projects.ts
1832
1952
  async function projectRoutes(app, opts) {
@@ -1834,43 +1954,39 @@ async function projectRoutes(app, opts) {
1834
1954
  const { name } = request.params;
1835
1955
  const parsedBody = projectUpsertRequestSchema.safeParse(request.body);
1836
1956
  if (!parsedBody.success) {
1837
- const err = validationError("Invalid project payload", {
1957
+ throw validationError("Invalid project payload", {
1838
1958
  issues: parsedBody.error.issues.map((issue) => ({
1839
1959
  path: issue.path.join("."),
1840
1960
  message: issue.message
1841
1961
  }))
1842
1962
  });
1843
- return reply.status(err.statusCode).send(err.toJSON());
1844
1963
  }
1845
1964
  const body = parsedBody.data;
1846
1965
  const validNames = opts.validProviderNames ?? [];
1847
1966
  if (validNames.length && body.providers?.length) {
1848
1967
  const invalid = body.providers.filter((p) => !validNames.includes(p));
1849
1968
  if (invalid.length) {
1850
- const err = validationError(`Invalid provider(s): ${invalid.join(", ")}. Must be one of: ${validNames.join(", ")}`, {
1969
+ throw validationError(`Invalid provider(s): ${invalid.join(", ")}. Must be one of: ${validNames.join(", ")}`, {
1851
1970
  invalidProviders: invalid,
1852
1971
  validProviders: validNames
1853
1972
  });
1854
- return reply.status(err.statusCode).send(err.toJSON());
1855
1973
  }
1856
1974
  }
1857
1975
  const now = (/* @__PURE__ */ new Date()).toISOString();
1858
1976
  const existing = app.db.select().from(projects).where(eq3(projects.name, name)).get();
1859
- const existingLocations = existing ? JSON.parse(existing.locations || "[]") : [];
1977
+ const existingLocations = existing ? parseJsonColumn(existing.locations, []) : [];
1860
1978
  const nextLocations = body.locations ?? existingLocations;
1861
1979
  const duplicateLabels = findDuplicateLocationLabels(nextLocations);
1862
1980
  if (duplicateLabels.length > 0) {
1863
- const err = validationError(`Duplicate location labels are not allowed: ${duplicateLabels.join(", ")}`, {
1981
+ throw validationError(`Duplicate location labels are not allowed: ${duplicateLabels.join(", ")}`, {
1864
1982
  duplicateLabels
1865
1983
  });
1866
- return reply.status(err.statusCode).send(err.toJSON());
1867
1984
  }
1868
1985
  const nextDefaultLocation = body.defaultLocation !== void 0 ? body.defaultLocation ?? null : existing?.defaultLocation ?? null;
1869
1986
  if (!hasLocationLabel(nextLocations, nextDefaultLocation)) {
1870
- const err = validationError(`defaultLocation "${nextDefaultLocation}" must match a configured location label`, {
1987
+ throw validationError(`defaultLocation "${nextDefaultLocation}" must match a configured location label`, {
1871
1988
  defaultLocation: nextDefaultLocation
1872
1989
  });
1873
- return reply.status(err.statusCode).send(err.toJSON());
1874
1990
  }
1875
1991
  if (existing) {
1876
1992
  app.db.update(projects).set({
@@ -1932,28 +2048,11 @@ async function projectRoutes(app, opts) {
1932
2048
  return reply.send(rows.map(formatProject));
1933
2049
  });
1934
2050
  app.get("/projects/:name", async (request, reply) => {
1935
- try {
1936
- const project = resolveProject(app.db, request.params.name);
1937
- return reply.send(formatProject(project));
1938
- } catch (e) {
1939
- if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
1940
- const err = e;
1941
- return reply.status(err.statusCode).send(err.toJSON());
1942
- }
1943
- throw e;
1944
- }
2051
+ const project = resolveProject(app.db, request.params.name);
2052
+ return reply.send(formatProject(project));
1945
2053
  });
1946
2054
  app.delete("/projects/:name", async (request, reply) => {
1947
- let project;
1948
- try {
1949
- project = resolveProject(app.db, request.params.name);
1950
- } catch (e) {
1951
- if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
1952
- const err = e;
1953
- return reply.status(err.statusCode).send(err.toJSON());
1954
- }
1955
- throw e;
1956
- }
2055
+ const project = resolveProject(app.db, request.params.name);
1957
2056
  writeAuditLog(app.db, {
1958
2057
  projectId: project.id,
1959
2058
  actor: "api",
@@ -1966,26 +2065,15 @@ async function projectRoutes(app, opts) {
1966
2065
  return reply.status(204).send();
1967
2066
  });
1968
2067
  app.post("/projects/:name/locations", async (request, reply) => {
1969
- let project;
1970
- try {
1971
- project = resolveProject(app.db, request.params.name);
1972
- } catch (e) {
1973
- if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
1974
- const err = e;
1975
- return reply.status(err.statusCode).send(err.toJSON());
1976
- }
1977
- throw e;
1978
- }
2068
+ const project = resolveProject(app.db, request.params.name);
1979
2069
  const parsed = locationContextSchema.safeParse(request.body);
1980
2070
  if (!parsed.success) {
1981
- const err = validationError(parsed.error.issues.map((i) => i.message).join(", "));
1982
- return reply.status(err.statusCode).send(err.toJSON());
2071
+ throw validationError(parsed.error.issues.map((i) => i.message).join(", "));
1983
2072
  }
1984
2073
  const location = parsed.data;
1985
- const existing = JSON.parse(project.locations || "[]");
2074
+ const existing = parseJsonColumn(project.locations, []);
1986
2075
  if (existing.some((l) => l.label === location.label)) {
1987
- const err = validationError(`Location "${location.label}" already exists`);
1988
- return reply.status(err.statusCode).send(err.toJSON());
2076
+ throw validationError(`Location "${location.label}" already exists`);
1989
2077
  }
1990
2078
  existing.push(location);
1991
2079
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -2003,39 +2091,20 @@ async function projectRoutes(app, opts) {
2003
2091
  return reply.status(201).send(location);
2004
2092
  });
2005
2093
  app.get("/projects/:name/locations", async (request, reply) => {
2006
- let project;
2007
- try {
2008
- project = resolveProject(app.db, request.params.name);
2009
- } catch (e) {
2010
- if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
2011
- const err = e;
2012
- return reply.status(err.statusCode).send(err.toJSON());
2013
- }
2014
- throw e;
2015
- }
2016
- const locations = JSON.parse(project.locations || "[]");
2094
+ const project = resolveProject(app.db, request.params.name);
2095
+ const locations = parseJsonColumn(project.locations, []);
2017
2096
  return reply.send({
2018
2097
  locations,
2019
2098
  defaultLocation: project.defaultLocation
2020
2099
  });
2021
2100
  });
2022
2101
  app.delete("/projects/:name/locations/:label", async (request, reply) => {
2023
- let project;
2024
- try {
2025
- project = resolveProject(app.db, request.params.name);
2026
- } catch (e) {
2027
- if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
2028
- const err = e;
2029
- return reply.status(err.statusCode).send(err.toJSON());
2030
- }
2031
- throw e;
2032
- }
2102
+ const project = resolveProject(app.db, request.params.name);
2033
2103
  const label = decodeURIComponent(request.params.label);
2034
- const existing = JSON.parse(project.locations || "[]");
2104
+ const existing = parseJsonColumn(project.locations, []);
2035
2105
  const filtered = existing.filter((l) => l.label !== label);
2036
2106
  if (filtered.length === existing.length) {
2037
- const err = validationError(`Location "${label}" not found`);
2038
- return reply.status(err.statusCode).send(err.toJSON());
2107
+ throw validationError(`Location "${label}" not found`);
2039
2108
  }
2040
2109
  const now = (/* @__PURE__ */ new Date()).toISOString();
2041
2110
  const updates = {
@@ -2056,25 +2125,14 @@ async function projectRoutes(app, opts) {
2056
2125
  return reply.status(204).send();
2057
2126
  });
2058
2127
  app.put("/projects/:name/locations/default", async (request, reply) => {
2059
- let project;
2060
- try {
2061
- project = resolveProject(app.db, request.params.name);
2062
- } catch (e) {
2063
- if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
2064
- const err = e;
2065
- return reply.status(err.statusCode).send(err.toJSON());
2066
- }
2067
- throw e;
2068
- }
2128
+ const project = resolveProject(app.db, request.params.name);
2069
2129
  const label = request.body?.label;
2070
2130
  if (!label) {
2071
- const err = validationError("label is required");
2072
- return reply.status(err.statusCode).send(err.toJSON());
2131
+ throw validationError("label is required");
2073
2132
  }
2074
- const existing = JSON.parse(project.locations || "[]");
2133
+ const existing = parseJsonColumn(project.locations, []);
2075
2134
  if (!existing.some((l) => l.label === label)) {
2076
- const err = validationError(`Location "${label}" not found. Add it first.`);
2077
- return reply.status(err.statusCode).send(err.toJSON());
2135
+ throw validationError(`Location "${label}" not found. Add it first.`);
2078
2136
  }
2079
2137
  const now = (/* @__PURE__ */ new Date()).toISOString();
2080
2138
  app.db.update(projects).set({
@@ -2091,16 +2149,7 @@ async function projectRoutes(app, opts) {
2091
2149
  return reply.send({ defaultLocation: label });
2092
2150
  });
2093
2151
  app.get("/projects/:name/export", async (request, reply) => {
2094
- let project;
2095
- try {
2096
- project = resolveProject(app.db, request.params.name);
2097
- } catch (e) {
2098
- if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
2099
- const err = e;
2100
- return reply.status(err.statusCode).send(err.toJSON());
2101
- }
2102
- throw e;
2103
- }
2152
+ const project = resolveProject(app.db, request.params.name);
2104
2153
  const kws = app.db.select().from(keywords).where(eq3(keywords.projectId, project.id)).all();
2105
2154
  const comps = app.db.select().from(competitors).where(eq3(competitors.projectId, project.id)).all();
2106
2155
  const schedule = app.db.select().from(schedules).where(eq3(schedules.projectId, project.id)).get();
@@ -2110,21 +2159,21 @@ async function projectRoutes(app, opts) {
2110
2159
  kind: "Project",
2111
2160
  metadata: {
2112
2161
  name: project.name,
2113
- labels: JSON.parse(project.labels)
2162
+ labels: parseJsonColumn(project.labels, {})
2114
2163
  },
2115
2164
  spec: {
2116
2165
  displayName: project.displayName,
2117
2166
  canonicalDomain: project.canonicalDomain,
2118
- ownedDomains: JSON.parse(project.ownedDomains || "[]"),
2167
+ ownedDomains: parseJsonColumn(project.ownedDomains, []),
2119
2168
  country: project.country,
2120
2169
  language: project.language,
2121
2170
  keywords: kws.map((k) => k.keyword),
2122
2171
  competitors: comps.map((c) => c.domain),
2123
- providers: JSON.parse(project.providers || "[]"),
2124
- locations: JSON.parse(project.locations || "[]"),
2172
+ providers: parseJsonColumn(project.providers, []),
2173
+ locations: parseJsonColumn(project.locations, []),
2125
2174
  ...project.defaultLocation ? { defaultLocation: project.defaultLocation } : {},
2126
2175
  notifications: notificationRows.map((row) => {
2127
- const cfg = JSON.parse(row.config);
2176
+ const cfg = parseJsonColumn(row.config, { url: "", events: [] });
2128
2177
  return {
2129
2178
  channel: row.channel,
2130
2179
  url: cfg.url,
@@ -2135,7 +2184,7 @@ async function projectRoutes(app, opts) {
2135
2184
  schedule: {
2136
2185
  ...schedule.preset ? { preset: schedule.preset } : { cron: schedule.cronExpr },
2137
2186
  timezone: schedule.timezone,
2138
- providers: JSON.parse(schedule.providers || "[]")
2187
+ providers: parseJsonColumn(schedule.providers, [])
2139
2188
  }
2140
2189
  } : {}
2141
2190
  }
@@ -2149,13 +2198,13 @@ function formatProject(row) {
2149
2198
  name: row.name,
2150
2199
  displayName: row.displayName,
2151
2200
  canonicalDomain: row.canonicalDomain,
2152
- ownedDomains: JSON.parse(row.ownedDomains || "[]"),
2201
+ ownedDomains: parseJsonColumn(row.ownedDomains, []),
2153
2202
  country: row.country,
2154
2203
  language: row.language,
2155
- tags: JSON.parse(row.tags),
2156
- labels: JSON.parse(row.labels),
2157
- providers: JSON.parse(row.providers || "[]"),
2158
- locations: JSON.parse(row.locations || "[]"),
2204
+ tags: parseJsonColumn(row.tags, []),
2205
+ labels: parseJsonColumn(row.labels, {}),
2206
+ providers: parseJsonColumn(row.providers, []),
2207
+ locations: parseJsonColumn(row.locations, []),
2159
2208
  defaultLocation: row.defaultLocation,
2160
2209
  configSource: row.configSource,
2161
2210
  configRevision: row.configRevision,
@@ -2169,18 +2218,15 @@ import crypto5 from "crypto";
2169
2218
  import { eq as eq4 } from "drizzle-orm";
2170
2219
  async function keywordRoutes(app, opts) {
2171
2220
  app.get("/projects/:name/keywords", async (request, reply) => {
2172
- const project = resolveProjectSafe(app, request.params.name, reply);
2173
- if (!project) return;
2221
+ const project = resolveProject(app.db, request.params.name);
2174
2222
  const rows = app.db.select().from(keywords).where(eq4(keywords.projectId, project.id)).all();
2175
2223
  return reply.send(rows.map((r) => ({ id: r.id, keyword: r.keyword, createdAt: r.createdAt })));
2176
2224
  });
2177
2225
  app.put("/projects/:name/keywords", async (request, reply) => {
2178
- const project = resolveProjectSafe(app, request.params.name, reply);
2179
- if (!project) return;
2226
+ const project = resolveProject(app.db, request.params.name);
2180
2227
  const body = request.body;
2181
2228
  if (!body || !Array.isArray(body.keywords)) {
2182
- const err = validationError('Body must contain a "keywords" array');
2183
- return reply.status(err.statusCode).send(err.toJSON());
2229
+ throw validationError('Body must contain a "keywords" array');
2184
2230
  }
2185
2231
  const now = (/* @__PURE__ */ new Date()).toISOString();
2186
2232
  app.db.transaction((tx) => {
@@ -2205,12 +2251,10 @@ async function keywordRoutes(app, opts) {
2205
2251
  return reply.send(rows.map((r) => ({ id: r.id, keyword: r.keyword, createdAt: r.createdAt })));
2206
2252
  });
2207
2253
  app.delete("/projects/:name/keywords", async (request, reply) => {
2208
- const project = resolveProjectSafe(app, request.params.name, reply);
2209
- if (!project) return;
2254
+ const project = resolveProject(app.db, request.params.name);
2210
2255
  const body = request.body;
2211
2256
  if (!body || !Array.isArray(body.keywords) || body.keywords.length === 0) {
2212
- const err = validationError('Body must contain a non-empty "keywords" array');
2213
- return reply.status(err.statusCode).send(err.toJSON());
2257
+ throw validationError('Body must contain a non-empty "keywords" array');
2214
2258
  }
2215
2259
  const existing = app.db.select().from(keywords).where(eq4(keywords.projectId, project.id)).all();
2216
2260
  const toDelete = new Set(body.keywords);
@@ -2233,12 +2277,10 @@ async function keywordRoutes(app, opts) {
2233
2277
  return reply.send(rows.map((r) => ({ id: r.id, keyword: r.keyword, createdAt: r.createdAt })));
2234
2278
  });
2235
2279
  app.post("/projects/:name/keywords", async (request, reply) => {
2236
- const project = resolveProjectSafe(app, request.params.name, reply);
2237
- if (!project) return;
2280
+ const project = resolveProject(app.db, request.params.name);
2238
2281
  const body = request.body;
2239
2282
  if (!body || !Array.isArray(body.keywords)) {
2240
- const err = validationError('Body must contain a "keywords" array');
2241
- return reply.status(err.statusCode).send(err.toJSON());
2283
+ throw validationError('Body must contain a "keywords" array');
2242
2284
  }
2243
2285
  const now = (/* @__PURE__ */ new Date()).toISOString();
2244
2286
  const existing = app.db.select().from(keywords).where(eq4(keywords.projectId, project.id)).all();
@@ -2269,30 +2311,25 @@ async function keywordRoutes(app, opts) {
2269
2311
  return reply.send(rows.map((r) => ({ id: r.id, keyword: r.keyword, createdAt: r.createdAt })));
2270
2312
  });
2271
2313
  app.post("/projects/:name/keywords/generate", async (request, reply) => {
2272
- const project = resolveProjectSafe(app, request.params.name, reply);
2273
- if (!project) return;
2314
+ const project = resolveProject(app.db, request.params.name);
2274
2315
  const body = request.body;
2275
2316
  if (!body?.provider || typeof body.provider !== "string") {
2276
- const err = validationError('Body must contain a "provider" string');
2277
- return reply.status(err.statusCode).send(err.toJSON());
2317
+ throw validationError('Body must contain a "provider" string');
2278
2318
  }
2279
2319
  const provider = body.provider.trim().toLowerCase();
2280
2320
  const validNames = opts.validProviderNames ?? [];
2281
2321
  if (validNames.length && !validNames.includes(provider)) {
2282
- const err = validationError(`Unknown provider "${body.provider}". Valid providers: ${validNames.join(", ")}`, {
2322
+ throw validationError(`Unknown provider "${body.provider}". Valid providers: ${validNames.join(", ")}`, {
2283
2323
  provider: body.provider,
2284
2324
  validProviders: validNames
2285
2325
  });
2286
- return reply.status(err.statusCode).send(err.toJSON());
2287
2326
  }
2288
2327
  if (body.count !== void 0 && (typeof body.count !== "number" || !Number.isFinite(body.count) || !Number.isInteger(body.count))) {
2289
- const err = validationError('"count" must be an integer');
2290
- return reply.status(err.statusCode).send(err.toJSON());
2328
+ throw validationError('"count" must be an integer');
2291
2329
  }
2292
2330
  const count = Math.min(Math.max(body.count ?? 5, 1), 20);
2293
2331
  if (!opts.onGenerateKeywords) {
2294
- const err = notImplemented("Key phrase generation is not supported in this deployment");
2295
- return reply.status(err.statusCode).send(err.toJSON());
2332
+ throw notImplemented("Key phrase generation is not supported in this deployment");
2296
2333
  }
2297
2334
  const existingRows = app.db.select().from(keywords).where(eq4(keywords.projectId, project.id)).all();
2298
2335
  const existingKeywords = existingRows.map((r) => r.keyword);
@@ -2316,36 +2353,21 @@ async function keywordRoutes(app, opts) {
2316
2353
  }
2317
2354
  });
2318
2355
  }
2319
- function resolveProjectSafe(app, name, reply) {
2320
- try {
2321
- return resolveProject(app.db, name);
2322
- } catch (e) {
2323
- if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
2324
- const err = e;
2325
- reply.status(err.statusCode).send(err.toJSON());
2326
- return null;
2327
- }
2328
- throw e;
2329
- }
2330
- }
2331
2356
 
2332
2357
  // ../api-routes/src/competitors.ts
2333
2358
  import crypto6 from "crypto";
2334
2359
  import { eq as eq5 } from "drizzle-orm";
2335
2360
  async function competitorRoutes(app) {
2336
2361
  app.get("/projects/:name/competitors", async (request, reply) => {
2337
- const project = resolveProjectSafe2(app, request.params.name, reply);
2338
- if (!project) return;
2362
+ const project = resolveProject(app.db, request.params.name);
2339
2363
  const rows = app.db.select().from(competitors).where(eq5(competitors.projectId, project.id)).all();
2340
2364
  return reply.send(rows.map((r) => ({ id: r.id, domain: r.domain, createdAt: r.createdAt })));
2341
2365
  });
2342
2366
  app.put("/projects/:name/competitors", async (request, reply) => {
2343
- const project = resolveProjectSafe2(app, request.params.name, reply);
2344
- if (!project) return;
2367
+ const project = resolveProject(app.db, request.params.name);
2345
2368
  const body = request.body;
2346
2369
  if (!body || !Array.isArray(body.competitors)) {
2347
- const err = validationError('Body must contain a "competitors" array');
2348
- return reply.status(err.statusCode).send(err.toJSON());
2370
+ throw validationError('Body must contain a "competitors" array');
2349
2371
  }
2350
2372
  const now = (/* @__PURE__ */ new Date()).toISOString();
2351
2373
  app.db.transaction((tx) => {
@@ -2370,18 +2392,6 @@ async function competitorRoutes(app) {
2370
2392
  return reply.send(rows.map((r) => ({ id: r.id, domain: r.domain, createdAt: r.createdAt })));
2371
2393
  });
2372
2394
  }
2373
- function resolveProjectSafe2(app, name, reply) {
2374
- try {
2375
- return resolveProject(app.db, name);
2376
- } catch (e) {
2377
- if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
2378
- const err = e;
2379
- reply.status(err.statusCode).send(err.toJSON());
2380
- return null;
2381
- }
2382
- throw e;
2383
- }
2384
- }
2385
2395
 
2386
2396
  // ../api-routes/src/runs.ts
2387
2397
  import crypto8 from "crypto";
@@ -2389,7 +2399,7 @@ import { eq as eq7, asc, desc } from "drizzle-orm";
2389
2399
 
2390
2400
  // ../api-routes/src/run-queue.ts
2391
2401
  import crypto7 from "crypto";
2392
- import { and as and2, eq as eq6, or } from "drizzle-orm";
2402
+ import { and, eq as eq6, or } from "drizzle-orm";
2393
2403
  function queueRunIfProjectIdle(db, params) {
2394
2404
  const createdAt = params.createdAt ?? (/* @__PURE__ */ new Date()).toISOString();
2395
2405
  const kind = params.kind ?? "answer-visibility";
@@ -2397,7 +2407,7 @@ function queueRunIfProjectIdle(db, params) {
2397
2407
  const runId = crypto7.randomUUID();
2398
2408
  return db.transaction((tx) => {
2399
2409
  const activeRun = tx.select().from(runs).where(
2400
- and2(
2410
+ and(
2401
2411
  eq6(runs.projectId, params.projectId),
2402
2412
  or(eq6(runs.status, "queued"), eq6(runs.status, "running"))
2403
2413
  )
@@ -2421,13 +2431,9 @@ function queueRunIfProjectIdle(db, params) {
2421
2431
  // ../api-routes/src/runs.ts
2422
2432
  async function runRoutes(app, opts) {
2423
2433
  app.post("/projects/:name/runs", async (request, reply) => {
2424
- const project = resolveProjectSafe3(app, request.params.name, reply);
2425
- if (!project) return;
2434
+ const project = resolveProject(app.db, request.params.name);
2426
2435
  const kind = request.body?.kind ?? "answer-visibility";
2427
- if (kind !== "answer-visibility") {
2428
- const err = unsupportedKind(kind);
2429
- return reply.status(err.statusCode).send(err.toJSON());
2430
- }
2436
+ if (kind !== "answer-visibility") throw unsupportedKind(kind);
2431
2437
  const now = (/* @__PURE__ */ new Date()).toISOString();
2432
2438
  const trigger = request.body?.trigger ?? "manual";
2433
2439
  const rawProviders = request.body?.providers;
@@ -2437,31 +2443,30 @@ async function runRoutes(app, opts) {
2437
2443
  if (validNames.length) {
2438
2444
  const invalid = normalized.filter((p) => !validNames.includes(p));
2439
2445
  if (invalid.length) {
2440
- const err = validationError(`Invalid provider(s): ${invalid.join(", ")}. Must be one of: ${validNames.join(", ")}`, {
2446
+ throw validationError(`Invalid provider(s): ${invalid.join(", ")}. Must be one of: ${validNames.join(", ")}`, {
2441
2447
  invalidProviders: invalid,
2442
2448
  validProviders: validNames
2443
2449
  });
2444
- return reply.status(err.statusCode).send(err.toJSON());
2445
2450
  }
2446
2451
  }
2447
2452
  rawProviders.splice(0, rawProviders.length, ...normalized);
2448
2453
  }
2449
2454
  const providers = rawProviders?.length ? rawProviders : void 0;
2450
2455
  let resolvedLocation;
2451
- const projectLocations = JSON.parse(project.locations || "[]");
2456
+ const projectLocations = parseJsonColumn(project.locations, []);
2452
2457
  if (request.body?.noLocation) {
2453
2458
  resolvedLocation = null;
2454
2459
  } else if (request.body?.allLocations) {
2455
2460
  } else if (request.body?.location) {
2456
2461
  const loc = projectLocations.find((l) => l.label === request.body.location);
2457
2462
  if (!loc) {
2458
- return reply.status(400).send({ error: { code: "VALIDATION_ERROR", message: `Location "${request.body.location}" not found. Configure it first.` } });
2463
+ throw validationError(`Location "${request.body.location}" not found. Configure it first.`);
2459
2464
  }
2460
2465
  resolvedLocation = loc;
2461
2466
  }
2462
2467
  if (request.body?.allLocations) {
2463
2468
  if (projectLocations.length === 0) {
2464
- return reply.status(400).send({ error: { code: "VALIDATION_ERROR", message: "No locations configured for this project" } });
2469
+ throw validationError("No locations configured for this project");
2465
2470
  }
2466
2471
  const newRuns = [];
2467
2472
  for (const loc of projectLocations) {
@@ -2502,10 +2507,7 @@ async function runRoutes(app, opts) {
2502
2507
  trigger,
2503
2508
  location: locationLabel
2504
2509
  });
2505
- if (queueResult.conflict) {
2506
- const err = runInProgress(project.name);
2507
- return reply.status(err.statusCode).send(err.toJSON());
2508
- }
2510
+ if (queueResult.conflict) throw runInProgress(project.name);
2509
2511
  const runId = queueResult.runId;
2510
2512
  writeAuditLog(app.db, {
2511
2513
  projectId: project.id,
@@ -2521,8 +2523,7 @@ async function runRoutes(app, opts) {
2521
2523
  return reply.status(201).send(formatRun(run));
2522
2524
  });
2523
2525
  app.get("/projects/:name/runs", async (request, reply) => {
2524
- const project = resolveProjectSafe3(app, request.params.name, reply);
2525
- if (!project) return;
2526
+ const project = resolveProject(app.db, request.params.name);
2526
2527
  const parsedLimit = parseInt(request.query.limit ?? "", 10);
2527
2528
  const limit = Number.isNaN(parsedLimit) || parsedLimit <= 0 ? void 0 : parsedLimit;
2528
2529
  const rows = limit == null ? app.db.select().from(runs).where(eq7(runs.projectId, project.id)).orderBy(asc(runs.createdAt)).all() : app.db.select().from(runs).where(eq7(runs.projectId, project.id)).orderBy(desc(runs.createdAt)).limit(limit).all().reverse();
@@ -2538,10 +2539,7 @@ async function runRoutes(app, opts) {
2538
2539
  return reply.status(207).send([]);
2539
2540
  }
2540
2541
  const kind = request.body?.kind ?? "answer-visibility";
2541
- if (kind !== "answer-visibility") {
2542
- const err = unsupportedKind(kind);
2543
- return reply.status(err.statusCode).send(err.toJSON());
2544
- }
2542
+ if (kind !== "answer-visibility") throw unsupportedKind(kind);
2545
2543
  const rawProviders = request.body?.providers;
2546
2544
  if (rawProviders?.length) {
2547
2545
  const normalized = rawProviders.map((p) => p.trim().toLowerCase()).filter(Boolean);
@@ -2549,11 +2547,10 @@ async function runRoutes(app, opts) {
2549
2547
  if (validNames.length) {
2550
2548
  const invalid = normalized.filter((p) => !validNames.includes(p));
2551
2549
  if (invalid.length) {
2552
- const err = validationError(`Invalid provider(s): ${invalid.join(", ")}. Must be one of: ${validNames.join(", ")}`, {
2550
+ throw validationError(`Invalid provider(s): ${invalid.join(", ")}. Must be one of: ${validNames.join(", ")}`, {
2553
2551
  invalidProviders: invalid,
2554
2552
  validProviders: validNames
2555
2553
  });
2556
- return reply.status(err.statusCode).send(err.toJSON());
2557
2554
  }
2558
2555
  }
2559
2556
  rawProviders.splice(0, rawProviders.length, ...normalized);
@@ -2590,15 +2587,9 @@ async function runRoutes(app, opts) {
2590
2587
  });
2591
2588
  app.post("/runs/:id/cancel", async (request, reply) => {
2592
2589
  const run = app.db.select().from(runs).where(eq7(runs.id, request.params.id)).get();
2593
- if (!run) {
2594
- const err = notFound("Run", request.params.id);
2595
- return reply.status(err.statusCode).send(err.toJSON());
2596
- }
2590
+ if (!run) throw notFound("Run", request.params.id);
2597
2591
  const terminalStatuses = /* @__PURE__ */ new Set(["completed", "partial", "failed", "cancelled"]);
2598
- if (terminalStatuses.has(run.status)) {
2599
- const err = runNotCancellable(run.id, run.status);
2600
- return reply.status(err.statusCode).send(err.toJSON());
2601
- }
2592
+ if (terminalStatuses.has(run.status)) throw runNotCancellable(run.id, run.status);
2602
2593
  const now = (/* @__PURE__ */ new Date()).toISOString();
2603
2594
  app.db.update(runs).set({ status: "cancelled", finishedAt: now, error: "Cancelled by user" }).where(eq7(runs.id, run.id)).run();
2604
2595
  writeAuditLog(app.db, {
@@ -2613,9 +2604,12 @@ async function runRoutes(app, opts) {
2613
2604
  });
2614
2605
  app.get("/runs/:id", async (request, reply) => {
2615
2606
  const run = app.db.select().from(runs).where(eq7(runs.id, request.params.id)).get();
2616
- if (!run) {
2617
- return reply.status(404).send({ error: { code: "NOT_FOUND", message: `Run '${request.params.id}' not found` } });
2618
- }
2607
+ if (!run) throw notFound("Run", request.params.id);
2608
+ const project = app.db.select({
2609
+ displayName: projects.displayName,
2610
+ canonicalDomain: projects.canonicalDomain,
2611
+ ownedDomains: projects.ownedDomains
2612
+ }).from(projects).where(eq7(projects.id, run.projectId)).get();
2619
2613
  const snapshots = app.db.select({
2620
2614
  id: querySnapshots.id,
2621
2615
  runId: querySnapshots.runId,
@@ -2624,6 +2618,7 @@ async function runRoutes(app, opts) {
2624
2618
  provider: querySnapshots.provider,
2625
2619
  model: querySnapshots.model,
2626
2620
  citationState: querySnapshots.citationState,
2621
+ answerMentioned: querySnapshots.answerMentioned,
2627
2622
  answerText: querySnapshots.answerText,
2628
2623
  citedDomains: querySnapshots.citedDomains,
2629
2624
  competitorOverlap: querySnapshots.competitorOverlap,
@@ -2636,6 +2631,7 @@ async function runRoutes(app, opts) {
2636
2631
  ...formatRun(run),
2637
2632
  snapshots: snapshots.map((s) => {
2638
2633
  const rawParsed = parseSnapshotRawResponse(s.rawResponse);
2634
+ const answerMentioned = project ? resolveSnapshotAnswerMentioned(s, project) : s.answerMentioned ?? false;
2639
2635
  return {
2640
2636
  id: s.id,
2641
2637
  runId: s.runId,
@@ -2643,10 +2639,12 @@ async function runRoutes(app, opts) {
2643
2639
  keyword: s.keyword,
2644
2640
  provider: s.provider,
2645
2641
  citationState: s.citationState,
2642
+ answerMentioned,
2643
+ visibilityState: project ? resolveSnapshotVisibilityState(s, project) : answerMentioned ? "visible" : "not-visible",
2646
2644
  answerText: s.answerText,
2647
- citedDomains: tryParseJson(s.citedDomains, []),
2648
- competitorOverlap: tryParseJson(s.competitorOverlap, []),
2649
- recommendedCompetitors: tryParseJson(s.recommendedCompetitors, []),
2645
+ citedDomains: parseJsonColumn(s.citedDomains, []),
2646
+ competitorOverlap: parseJsonColumn(s.competitorOverlap, []),
2647
+ recommendedCompetitors: parseJsonColumn(s.recommendedCompetitors, []),
2650
2648
  model: s.model ?? rawParsed.model,
2651
2649
  location: s.location,
2652
2650
  groundingSources: rawParsed.groundingSources,
@@ -2672,32 +2670,13 @@ function formatRun(row) {
2672
2670
  };
2673
2671
  }
2674
2672
  function parseSnapshotRawResponse(raw) {
2675
- const parsed = tryParseJson(raw ?? "{}", {});
2673
+ const parsed = parseJsonColumn(raw, {});
2676
2674
  return {
2677
2675
  groundingSources: parsed.groundingSources ?? [],
2678
2676
  searchQueries: parsed.searchQueries ?? [],
2679
2677
  model: parsed.model ?? null
2680
2678
  };
2681
2679
  }
2682
- function tryParseJson(value, fallback) {
2683
- try {
2684
- return JSON.parse(value);
2685
- } catch {
2686
- return fallback;
2687
- }
2688
- }
2689
- function resolveProjectSafe3(app, name, reply) {
2690
- try {
2691
- return resolveProject(app.db, name);
2692
- } catch (e) {
2693
- if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
2694
- const err = e;
2695
- reply.status(err.statusCode).send(err.toJSON());
2696
- return null;
2697
- }
2698
- throw e;
2699
- }
2700
- }
2701
2680
 
2702
2681
  // ../api-routes/src/apply.ts
2703
2682
  import crypto10 from "crypto";
@@ -2955,10 +2934,9 @@ async function applyRoutes(app, opts) {
2955
2934
  app.post("/apply", async (request, reply) => {
2956
2935
  const parsed = projectConfigSchema.safeParse(request.body);
2957
2936
  if (!parsed.success) {
2958
- const err = validationError("Invalid project config", {
2937
+ throw validationError("Invalid project config", {
2959
2938
  issues: parsed.error.issues.map((i) => ({ path: i.path.join("."), message: i.message }))
2960
2939
  });
2961
- return reply.status(err.statusCode).send(err.toJSON());
2962
2940
  }
2963
2941
  const config = parsed.data;
2964
2942
  const validNames = opts?.validProviderNames ?? [];
@@ -2970,70 +2948,104 @@ async function applyRoutes(app, opts) {
2970
2948
  if (allProviders.length) {
2971
2949
  const invalid = allProviders.filter((p) => !validNames.includes(p));
2972
2950
  if (invalid.length) {
2973
- const err = validationError(`Invalid provider(s): ${[...new Set(invalid)].join(", ")}. Must be one of: ${validNames.join(", ")}`, {
2951
+ throw validationError(`Invalid provider(s): ${[...new Set(invalid)].join(", ")}. Must be one of: ${validNames.join(", ")}`, {
2974
2952
  invalidProviders: [...new Set(invalid)],
2975
2953
  validProviders: validNames
2976
2954
  });
2977
- return reply.status(err.statusCode).send(err.toJSON());
2978
2955
  }
2979
2956
  }
2980
2957
  }
2958
+ let resolvedSchedule = null;
2959
+ let deleteSchedule = false;
2960
+ if (config.spec.schedule) {
2961
+ const schedSpec = config.spec.schedule;
2962
+ let cronExpr;
2963
+ let preset = null;
2964
+ if (schedSpec.preset) {
2965
+ preset = schedSpec.preset;
2966
+ try {
2967
+ cronExpr = resolvePreset(schedSpec.preset);
2968
+ } catch (err) {
2969
+ const msg = err instanceof Error ? err.message : String(err);
2970
+ throw validationError(msg);
2971
+ }
2972
+ } else if (schedSpec.cron) {
2973
+ cronExpr = schedSpec.cron;
2974
+ if (!validateCron(cronExpr)) throw validationError(`Invalid cron expression in schedule: ${cronExpr}`);
2975
+ } else {
2976
+ throw validationError('Schedule requires either "preset" or "cron"');
2977
+ }
2978
+ const timezone = schedSpec.timezone ?? "UTC";
2979
+ if (!isValidTimezone(timezone)) throw validationError(`Invalid timezone: ${timezone}`);
2980
+ resolvedSchedule = { cronExpr, preset, timezone };
2981
+ } else {
2982
+ deleteSchedule = true;
2983
+ }
2984
+ const rawSpec = request.body?.spec ?? {};
2985
+ const hasNotifications = "notifications" in rawSpec;
2986
+ if (hasNotifications) {
2987
+ for (const notif of config.spec.notifications) {
2988
+ const urlCheck = await resolveWebhookTarget(notif.url ?? "");
2989
+ if (!urlCheck.ok) throw validationError(`Notification URL invalid: ${urlCheck.message}`);
2990
+ }
2991
+ }
2981
2992
  const now = (/* @__PURE__ */ new Date()).toISOString();
2982
2993
  const name = config.metadata.name;
2983
- const existing = app.db.select().from(projects).where(eq8(projects.name, name)).get();
2984
2994
  let projectId;
2985
- if (existing) {
2986
- projectId = existing.id;
2987
- app.db.update(projects).set({
2988
- displayName: config.spec.displayName,
2989
- canonicalDomain: config.spec.canonicalDomain,
2990
- ownedDomains: JSON.stringify(config.spec.ownedDomains ?? []),
2991
- country: config.spec.country,
2992
- language: config.spec.language,
2993
- labels: JSON.stringify(config.metadata.labels),
2994
- providers: JSON.stringify(config.spec.providers ?? []),
2995
- locations: JSON.stringify(config.spec.locations ?? []),
2996
- defaultLocation: config.spec.defaultLocation ?? null,
2997
- configSource: "config-file",
2998
- configRevision: existing.configRevision + 1,
2999
- updatedAt: now
3000
- }).where(eq8(projects.id, existing.id)).run();
3001
- writeAuditLog(app.db, {
3002
- projectId,
3003
- actor: "api",
3004
- action: "project.applied",
3005
- entityType: "project",
3006
- entityId: projectId
3007
- });
3008
- } else {
3009
- projectId = crypto10.randomUUID();
3010
- app.db.insert(projects).values({
3011
- id: projectId,
3012
- name,
3013
- displayName: config.spec.displayName,
3014
- canonicalDomain: config.spec.canonicalDomain,
3015
- ownedDomains: JSON.stringify(config.spec.ownedDomains ?? []),
3016
- country: config.spec.country,
3017
- language: config.spec.language,
3018
- tags: "[]",
3019
- labels: JSON.stringify(config.metadata.labels),
3020
- providers: JSON.stringify(config.spec.providers ?? []),
3021
- locations: JSON.stringify(config.spec.locations ?? []),
3022
- defaultLocation: config.spec.defaultLocation ?? null,
3023
- configSource: "config-file",
3024
- configRevision: 1,
3025
- createdAt: now,
3026
- updatedAt: now
3027
- }).run();
3028
- writeAuditLog(app.db, {
3029
- projectId,
3030
- actor: "api",
3031
- action: "project.created",
3032
- entityType: "project",
3033
- entityId: projectId
3034
- });
3035
- }
2995
+ let scheduleAction = null;
3036
2996
  app.db.transaction((tx) => {
2997
+ const existing = tx.select().from(projects).where(eq8(projects.name, name)).get();
2998
+ if (existing) {
2999
+ projectId = existing.id;
3000
+ tx.update(projects).set({
3001
+ displayName: config.spec.displayName,
3002
+ canonicalDomain: config.spec.canonicalDomain,
3003
+ ownedDomains: JSON.stringify(config.spec.ownedDomains ?? []),
3004
+ country: config.spec.country,
3005
+ language: config.spec.language,
3006
+ labels: JSON.stringify(config.metadata.labels),
3007
+ providers: JSON.stringify(config.spec.providers ?? []),
3008
+ locations: JSON.stringify(config.spec.locations ?? []),
3009
+ defaultLocation: config.spec.defaultLocation ?? null,
3010
+ configSource: "config-file",
3011
+ configRevision: existing.configRevision + 1,
3012
+ updatedAt: now
3013
+ }).where(eq8(projects.id, existing.id)).run();
3014
+ writeAuditLog(tx, {
3015
+ projectId,
3016
+ actor: "api",
3017
+ action: "project.applied",
3018
+ entityType: "project",
3019
+ entityId: projectId
3020
+ });
3021
+ } else {
3022
+ projectId = crypto10.randomUUID();
3023
+ tx.insert(projects).values({
3024
+ id: projectId,
3025
+ name,
3026
+ displayName: config.spec.displayName,
3027
+ canonicalDomain: config.spec.canonicalDomain,
3028
+ ownedDomains: JSON.stringify(config.spec.ownedDomains ?? []),
3029
+ country: config.spec.country,
3030
+ language: config.spec.language,
3031
+ tags: "[]",
3032
+ labels: JSON.stringify(config.metadata.labels),
3033
+ providers: JSON.stringify(config.spec.providers ?? []),
3034
+ locations: JSON.stringify(config.spec.locations ?? []),
3035
+ defaultLocation: config.spec.defaultLocation ?? null,
3036
+ configSource: "config-file",
3037
+ configRevision: 1,
3038
+ createdAt: now,
3039
+ updatedAt: now
3040
+ }).run();
3041
+ writeAuditLog(tx, {
3042
+ projectId,
3043
+ actor: "api",
3044
+ action: "project.created",
3045
+ entityType: "project",
3046
+ entityId: projectId
3047
+ });
3048
+ }
3037
3049
  tx.delete(keywords).where(eq8(keywords.projectId, projectId)).run();
3038
3050
  for (const kw of config.spec.keywords) {
3039
3051
  tx.insert(keywords).values({
@@ -3066,98 +3078,63 @@ async function applyRoutes(app, opts) {
3066
3078
  entityType: "competitor",
3067
3079
  diff: { competitors: config.spec.competitors }
3068
3080
  });
3069
- });
3070
- if (config.spec.schedule) {
3071
- const schedSpec = config.spec.schedule;
3072
- let cronExpr;
3073
- let preset = null;
3074
- if (schedSpec.preset) {
3075
- preset = schedSpec.preset;
3076
- try {
3077
- cronExpr = resolvePreset(schedSpec.preset);
3078
- } catch (err) {
3079
- const msg = err instanceof Error ? err.message : String(err);
3080
- return reply.status(400).send({ error: { code: "VALIDATION_ERROR", message: msg } });
3081
+ if (resolvedSchedule) {
3082
+ const existingSched = tx.select().from(schedules).where(eq8(schedules.projectId, projectId)).get();
3083
+ if (existingSched) {
3084
+ tx.update(schedules).set({
3085
+ cronExpr: resolvedSchedule.cronExpr,
3086
+ preset: resolvedSchedule.preset,
3087
+ timezone: resolvedSchedule.timezone,
3088
+ providers: JSON.stringify(config.spec.schedule?.providers ?? []),
3089
+ enabled: 1,
3090
+ updatedAt: now
3091
+ }).where(eq8(schedules.id, existingSched.id)).run();
3092
+ } else {
3093
+ tx.insert(schedules).values({
3094
+ id: crypto10.randomUUID(),
3095
+ projectId,
3096
+ cronExpr: resolvedSchedule.cronExpr,
3097
+ preset: resolvedSchedule.preset,
3098
+ timezone: resolvedSchedule.timezone,
3099
+ enabled: 1,
3100
+ providers: JSON.stringify(config.spec.schedule?.providers ?? []),
3101
+ createdAt: now,
3102
+ updatedAt: now
3103
+ }).run();
3081
3104
  }
3082
- } else if (schedSpec.cron) {
3083
- cronExpr = schedSpec.cron;
3084
- if (!validateCron(cronExpr)) {
3085
- return reply.status(400).send({
3086
- error: { code: "VALIDATION_ERROR", message: `Invalid cron expression in schedule: ${cronExpr}` }
3087
- });
3105
+ scheduleAction = "upsert";
3106
+ } else if (deleteSchedule) {
3107
+ const existingSched = tx.select().from(schedules).where(eq8(schedules.projectId, projectId)).get();
3108
+ if (existingSched) {
3109
+ tx.delete(schedules).where(eq8(schedules.projectId, projectId)).run();
3110
+ scheduleAction = "delete";
3088
3111
  }
3089
- } else {
3090
- return reply.status(400).send({
3091
- error: { code: "VALIDATION_ERROR", message: 'Schedule requires either "preset" or "cron"' }
3092
- });
3093
- }
3094
- const timezone = schedSpec.timezone ?? "UTC";
3095
- if (!isValidTimezone(timezone)) {
3096
- return reply.status(400).send({
3097
- error: { code: "VALIDATION_ERROR", message: `Invalid timezone: ${timezone}` }
3098
- });
3099
- }
3100
- const existingSched = app.db.select().from(schedules).where(eq8(schedules.projectId, projectId)).get();
3101
- if (existingSched) {
3102
- app.db.update(schedules).set({
3103
- cronExpr,
3104
- preset,
3105
- timezone,
3106
- providers: JSON.stringify(schedSpec.providers ?? []),
3107
- enabled: 1,
3108
- updatedAt: now
3109
- }).where(eq8(schedules.id, existingSched.id)).run();
3110
- } else {
3111
- app.db.insert(schedules).values({
3112
- id: crypto10.randomUUID(),
3113
- projectId,
3114
- cronExpr,
3115
- preset,
3116
- timezone,
3117
- enabled: 1,
3118
- providers: JSON.stringify(schedSpec.providers ?? []),
3119
- createdAt: now,
3120
- updatedAt: now
3121
- }).run();
3122
- }
3123
- opts?.onScheduleUpdated?.("upsert", projectId);
3124
- } else {
3125
- const existingSched = app.db.select().from(schedules).where(eq8(schedules.projectId, projectId)).get();
3126
- if (existingSched) {
3127
- app.db.delete(schedules).where(eq8(schedules.projectId, projectId)).run();
3128
- opts?.onScheduleUpdated?.("delete", projectId);
3129
3112
  }
3130
- }
3131
- const rawSpec = request.body?.spec ?? {};
3132
- if ("notifications" in rawSpec) {
3133
- for (const notif of config.spec.notifications) {
3134
- const urlCheck = await resolveWebhookTarget(notif.url ?? "");
3135
- if (!urlCheck.ok) {
3136
- return reply.status(400).send({
3137
- error: { code: "VALIDATION_ERROR", message: `Notification URL invalid: ${urlCheck.message}` }
3138
- });
3113
+ if (hasNotifications) {
3114
+ tx.delete(notifications).where(eq8(notifications.projectId, projectId)).run();
3115
+ for (const notif of config.spec.notifications) {
3116
+ tx.insert(notifications).values({
3117
+ id: crypto10.randomUUID(),
3118
+ projectId,
3119
+ channel: notif.channel,
3120
+ config: JSON.stringify({ url: notif.url, events: notif.events }),
3121
+ webhookSecret: crypto10.randomBytes(32).toString("hex"),
3122
+ enabled: 1,
3123
+ createdAt: now,
3124
+ updatedAt: now
3125
+ }).run();
3139
3126
  }
3140
- }
3141
- app.db.delete(notifications).where(eq8(notifications.projectId, projectId)).run();
3142
- for (const notif of config.spec.notifications) {
3143
- app.db.insert(notifications).values({
3144
- id: crypto10.randomUUID(),
3127
+ writeAuditLog(tx, {
3145
3128
  projectId,
3146
- channel: notif.channel,
3147
- config: JSON.stringify({ url: notif.url, events: notif.events }),
3148
- webhookSecret: crypto10.randomBytes(32).toString("hex"),
3149
- enabled: 1,
3150
- createdAt: now,
3151
- updatedAt: now
3152
- }).run();
3129
+ actor: "api",
3130
+ action: "notifications.replaced",
3131
+ entityType: "notification",
3132
+ diff: { notifications: config.spec.notifications }
3133
+ });
3153
3134
  }
3154
- writeAuditLog(app.db, {
3155
- projectId,
3156
- actor: "api",
3157
- action: "notifications.replaced",
3158
- entityType: "notification",
3159
- diff: { notifications: config.spec.notifications }
3160
- });
3135
+ });
3136
+ if (scheduleAction) {
3137
+ opts?.onScheduleUpdated?.(scheduleAction, projectId);
3161
3138
  }
3162
3139
  if ("google" in rawSpec && config.spec.google?.gsc?.propertyUrl) {
3163
3140
  opts?.onGoogleConnectionPropertyUpdated?.(config.spec.canonicalDomain, "gsc", config.spec.google.gsc.propertyUrl);
@@ -3168,13 +3145,13 @@ async function applyRoutes(app, opts) {
3168
3145
  name: project.name,
3169
3146
  displayName: project.displayName,
3170
3147
  canonicalDomain: project.canonicalDomain,
3171
- ownedDomains: JSON.parse(project.ownedDomains || "[]"),
3148
+ ownedDomains: parseJsonColumn(project.ownedDomains, []),
3172
3149
  country: project.country,
3173
3150
  language: project.language,
3174
- tags: JSON.parse(project.tags),
3175
- labels: JSON.parse(project.labels),
3176
- providers: JSON.parse(project.providers || "[]"),
3177
- locations: JSON.parse(project.locations || "[]"),
3151
+ tags: parseJsonColumn(project.tags, []),
3152
+ labels: parseJsonColumn(project.labels, {}),
3153
+ providers: parseJsonColumn(project.providers, []),
3154
+ locations: parseJsonColumn(project.locations, []),
3178
3155
  defaultLocation: project.defaultLocation,
3179
3156
  configSource: project.configSource,
3180
3157
  configRevision: project.configRevision,
@@ -3230,8 +3207,7 @@ function redactNotificationDiff(value) {
3230
3207
  // ../api-routes/src/history.ts
3231
3208
  async function historyRoutes(app) {
3232
3209
  app.get("/projects/:name/history", async (request, reply) => {
3233
- const project = resolveProjectSafe4(app, request.params.name, reply);
3234
- if (!project) return;
3210
+ const project = resolveProject(app.db, request.params.name);
3235
3211
  const rows = app.db.select().from(auditLog).where(eq9(auditLog.projectId, project.id)).orderBy(desc2(auditLog.createdAt)).all();
3236
3212
  return reply.send(rows.map(formatAuditEntry));
3237
3213
  });
@@ -3240,8 +3216,7 @@ async function historyRoutes(app) {
3240
3216
  return reply.send(rows.map(formatAuditEntry));
3241
3217
  });
3242
3218
  app.get("/projects/:name/snapshots", async (request, reply) => {
3243
- const project = resolveProjectSafe4(app, request.params.name, reply);
3244
- if (!project) return;
3219
+ const project = resolveProject(app.db, request.params.name);
3245
3220
  const limit = parseInt(request.query.limit ?? "50", 10);
3246
3221
  const offset = parseInt(request.query.offset ?? "0", 10);
3247
3222
  const projectRuns = app.db.select({ id: runs.id }).from(runs).where(eq9(runs.projectId, project.id)).all();
@@ -3256,6 +3231,7 @@ async function historyRoutes(app) {
3256
3231
  provider: querySnapshots.provider,
3257
3232
  model: querySnapshots.model,
3258
3233
  citationState: querySnapshots.citationState,
3234
+ answerMentioned: querySnapshots.answerMentioned,
3259
3235
  answerText: querySnapshots.answerText,
3260
3236
  citedDomains: querySnapshots.citedDomains,
3261
3237
  competitorOverlap: querySnapshots.competitorOverlap,
@@ -3276,10 +3252,12 @@ async function historyRoutes(app) {
3276
3252
  provider: s.provider,
3277
3253
  model: s.model,
3278
3254
  citationState: s.citationState,
3255
+ answerMentioned: resolveSnapshotAnswerMentioned(s, project),
3256
+ visibilityState: resolveSnapshotVisibilityState(s, project),
3279
3257
  answerText: s.answerText,
3280
- citedDomains: tryParseJson2(s.citedDomains, []),
3281
- competitorOverlap: tryParseJson2(s.competitorOverlap, []),
3282
- recommendedCompetitors: tryParseJson2(s.recommendedCompetitors, []),
3258
+ citedDomains: parseJsonColumn(s.citedDomains, []),
3259
+ competitorOverlap: parseJsonColumn(s.competitorOverlap, []),
3260
+ recommendedCompetitors: parseJsonColumn(s.recommendedCompetitors, []),
3283
3261
  location: s.location,
3284
3262
  createdAt: s.createdAt
3285
3263
  })),
@@ -3287,8 +3265,7 @@ async function historyRoutes(app) {
3287
3265
  });
3288
3266
  });
3289
3267
  app.get("/projects/:name/timeline", async (request, reply) => {
3290
- const project = resolveProjectSafe4(app, request.params.name, reply);
3291
- if (!project) return;
3268
+ const project = resolveProject(app.db, request.params.name);
3292
3269
  const projectKeywords = app.db.select().from(keywords).where(eq9(keywords.projectId, project.id)).all();
3293
3270
  const projectRuns = app.db.select().from(runs).where(eq9(runs.projectId, project.id)).orderBy(runs.createdAt).all();
3294
3271
  if (projectRuns.length === 0 || projectKeywords.length === 0) {
@@ -3297,12 +3274,16 @@ async function historyRoutes(app) {
3297
3274
  const runIds = new Set(projectRuns.map((r) => r.id));
3298
3275
  const rawSnapshots = app.db.select().from(querySnapshots).where(inArray(querySnapshots.runId, [...runIds])).all();
3299
3276
  const timelineLocationFilter = request.query.location;
3300
- const allSnapshots = timelineLocationFilter !== void 0 ? rawSnapshots.filter((s) => s.location === (timelineLocationFilter || null)) : rawSnapshots;
3277
+ const filteredSnapshots = timelineLocationFilter !== void 0 ? rawSnapshots.filter((s) => s.location === (timelineLocationFilter || null)) : rawSnapshots;
3278
+ const allSnapshots = filteredSnapshots.map((snapshot) => ({
3279
+ ...snapshot,
3280
+ answerMentioned: resolveSnapshotAnswerMentioned(snapshot, project)
3281
+ }));
3301
3282
  const deduped = /* @__PURE__ */ new Map();
3302
3283
  for (const snap of allSnapshots) {
3303
3284
  const key = `${snap.runId}:${snap.keywordId}`;
3304
3285
  const existing = deduped.get(key);
3305
- if (!existing || snap.citationState === "cited") {
3286
+ if (!existing || !existing.answerMentioned && snap.answerMentioned || existing.answerMentioned === snap.answerMentioned && snap.citationState === "cited") {
3306
3287
  deduped.set(key, snap);
3307
3288
  }
3308
3289
  }
@@ -3325,8 +3306,10 @@ async function historyRoutes(app) {
3325
3306
  return snaps.map((snap, idx) => {
3326
3307
  const run = projectRuns.find((r) => r.id === snap.runId);
3327
3308
  let transition = snap.citationState === "cited" ? "cited" : "not-cited";
3309
+ let visibilityTransition = snap.answerMentioned ? "visible" : "not-visible";
3328
3310
  if (idx === 0) {
3329
3311
  transition = "new";
3312
+ visibilityTransition = "new";
3330
3313
  } else {
3331
3314
  const prev = snaps[idx - 1];
3332
3315
  if (prev.citationState === "not-cited" && snap.citationState === "cited") {
@@ -3334,12 +3317,20 @@ async function historyRoutes(app) {
3334
3317
  } else if (prev.citationState === "cited" && snap.citationState === "not-cited") {
3335
3318
  transition = "lost";
3336
3319
  }
3320
+ if (!prev.answerMentioned && snap.answerMentioned) {
3321
+ visibilityTransition = "emerging";
3322
+ } else if (prev.answerMentioned && !snap.answerMentioned) {
3323
+ visibilityTransition = "lost";
3324
+ }
3337
3325
  }
3338
3326
  return {
3339
3327
  runId: snap.runId,
3340
3328
  createdAt: run?.createdAt ?? snap.createdAt,
3341
3329
  citationState: snap.citationState,
3342
- transition
3330
+ transition,
3331
+ answerMentioned: snap.answerMentioned,
3332
+ visibilityState: snap.answerMentioned ? "visible" : "not-visible",
3333
+ visibilityTransition
3343
3334
  };
3344
3335
  });
3345
3336
  }
@@ -3370,31 +3361,48 @@ async function historyRoutes(app) {
3370
3361
  return reply.send(timeline);
3371
3362
  });
3372
3363
  app.get("/projects/:name/snapshots/diff", async (request, reply) => {
3373
- const project = resolveProjectSafe4(app, request.params.name, reply);
3374
- if (!project) return;
3364
+ const project = resolveProject(app.db, request.params.name);
3375
3365
  const { run1, run2 } = request.query;
3376
3366
  if (!run1 || !run2) {
3377
- return reply.status(400).send({ error: { code: "VALIDATION_ERROR", message: "Both run1 and run2 query params are required" } });
3367
+ throw validationError("Both run1 and run2 query params are required");
3378
3368
  }
3379
3369
  const snaps1 = app.db.select({
3380
3370
  keywordId: querySnapshots.keywordId,
3381
3371
  keyword: keywords.keyword,
3382
- citationState: querySnapshots.citationState
3372
+ citationState: querySnapshots.citationState,
3373
+ answerMentioned: querySnapshots.answerMentioned,
3374
+ answerText: querySnapshots.answerText
3383
3375
  }).from(querySnapshots).leftJoin(keywords, eq9(querySnapshots.keywordId, keywords.id)).where(eq9(querySnapshots.runId, run1)).all();
3384
3376
  const snaps2 = app.db.select({
3385
3377
  keywordId: querySnapshots.keywordId,
3386
3378
  keyword: keywords.keyword,
3387
- citationState: querySnapshots.citationState
3379
+ citationState: querySnapshots.citationState,
3380
+ answerMentioned: querySnapshots.answerMentioned,
3381
+ answerText: querySnapshots.answerText
3388
3382
  }).from(querySnapshots).leftJoin(keywords, eq9(querySnapshots.keywordId, keywords.id)).where(eq9(querySnapshots.runId, run2)).all();
3389
3383
  const map1 = /* @__PURE__ */ new Map();
3390
3384
  for (const s of snaps1) {
3385
+ const resolved = {
3386
+ ...s,
3387
+ resolvedAnswerMentioned: resolveSnapshotAnswerMentioned(s, project),
3388
+ resolvedVisibilityState: resolveSnapshotVisibilityState(s, project)
3389
+ };
3391
3390
  const existing = map1.get(s.keywordId);
3392
- if (!existing || s.citationState === "cited") map1.set(s.keywordId, s);
3391
+ if (!existing || !existing.resolvedAnswerMentioned && resolved.resolvedAnswerMentioned || existing.resolvedAnswerMentioned === resolved.resolvedAnswerMentioned && resolved.citationState === "cited") {
3392
+ map1.set(s.keywordId, resolved);
3393
+ }
3393
3394
  }
3394
3395
  const map2 = /* @__PURE__ */ new Map();
3395
3396
  for (const s of snaps2) {
3397
+ const resolved = {
3398
+ ...s,
3399
+ resolvedAnswerMentioned: resolveSnapshotAnswerMentioned(s, project),
3400
+ resolvedVisibilityState: resolveSnapshotVisibilityState(s, project)
3401
+ };
3396
3402
  const existing = map2.get(s.keywordId);
3397
- if (!existing || s.citationState === "cited") map2.set(s.keywordId, s);
3403
+ if (!existing || !existing.resolvedAnswerMentioned && resolved.resolvedAnswerMentioned || existing.resolvedAnswerMentioned === resolved.resolvedAnswerMentioned && resolved.citationState === "cited") {
3404
+ map2.set(s.keywordId, resolved);
3405
+ }
3398
3406
  }
3399
3407
  const allKeywordIds = /* @__PURE__ */ new Set([...map1.keys(), ...map2.keys()]);
3400
3408
  const diff = [...allKeywordIds].map((kwId) => {
@@ -3405,7 +3413,12 @@ async function historyRoutes(app) {
3405
3413
  keyword: s2?.keyword ?? s1?.keyword ?? null,
3406
3414
  run1State: s1?.citationState ?? null,
3407
3415
  run2State: s2?.citationState ?? null,
3408
- changed: (s1?.citationState ?? null) !== (s2?.citationState ?? null)
3416
+ run1AnswerMentioned: s1?.resolvedAnswerMentioned ?? null,
3417
+ run2AnswerMentioned: s2?.resolvedAnswerMentioned ?? null,
3418
+ run1VisibilityState: s1?.resolvedVisibilityState ?? null,
3419
+ run2VisibilityState: s2?.resolvedVisibilityState ?? null,
3420
+ changed: (s1?.citationState ?? null) !== (s2?.citationState ?? null),
3421
+ visibilityChanged: (s1?.resolvedAnswerMentioned ?? null) !== (s2?.resolvedAnswerMentioned ?? null)
3409
3422
  };
3410
3423
  });
3411
3424
  return reply.send({ run1, run2, diff });
@@ -3419,36 +3432,16 @@ function formatAuditEntry(row) {
3419
3432
  action: row.action,
3420
3433
  entityType: row.entityType,
3421
3434
  entityId: row.entityId,
3422
- diff: row.diff ? row.entityType === "notification" ? redactNotificationDiff(tryParseJson2(row.diff, null)) : tryParseJson2(row.diff, null) : null,
3435
+ diff: row.diff ? row.entityType === "notification" ? redactNotificationDiff(parseJsonColumn(row.diff, null)) : parseJsonColumn(row.diff, null) : null,
3423
3436
  createdAt: row.createdAt
3424
3437
  };
3425
3438
  }
3426
- function tryParseJson2(value, fallback) {
3427
- try {
3428
- return JSON.parse(value);
3429
- } catch {
3430
- return fallback;
3431
- }
3432
- }
3433
- function resolveProjectSafe4(app, name, reply) {
3434
- try {
3435
- return resolveProject(app.db, name);
3436
- } catch (e) {
3437
- if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
3438
- const err = e;
3439
- reply.status(err.statusCode).send(err.toJSON());
3440
- return null;
3441
- }
3442
- throw e;
3443
- }
3444
- }
3445
3439
 
3446
3440
  // ../api-routes/src/analytics.ts
3447
3441
  import { eq as eq10, desc as desc3, inArray as inArray2 } from "drizzle-orm";
3448
3442
  async function analyticsRoutes(app) {
3449
3443
  app.get("/projects/:name/analytics/metrics", async (request, reply) => {
3450
- const project = resolveProjectSafe5(app, request.params.name, reply);
3451
- if (!project) return;
3444
+ const project = resolveProject(app.db, request.params.name);
3452
3445
  const window = parseWindow(request.query.window);
3453
3446
  const cutoff = windowCutoff(window);
3454
3447
  const projectRuns = 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);
@@ -3488,8 +3481,7 @@ async function analyticsRoutes(app) {
3488
3481
  return reply.send({ window, buckets, overall, byProvider, trend, keywordChanges });
3489
3482
  });
3490
3483
  app.get("/projects/:name/analytics/gaps", async (request, reply) => {
3491
- const project = resolveProjectSafe5(app, request.params.name, reply);
3492
- if (!project) return;
3484
+ const project = resolveProject(app.db, request.params.name);
3493
3485
  const window = parseWindow(request.query.window);
3494
3486
  const cutoff = windowCutoff(window);
3495
3487
  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");
@@ -3537,7 +3529,7 @@ async function analyticsRoutes(app) {
3537
3529
  const citedProviders = kwSnapshots.filter((s) => s.citationState === "cited").map((s) => s.provider);
3538
3530
  const competitorsCiting = /* @__PURE__ */ new Set();
3539
3531
  for (const s of kwSnapshots) {
3540
- const overlap = tryParseJson3(s.competitorOverlap, []);
3532
+ const overlap = parseJsonColumn(s.competitorOverlap, []);
3541
3533
  for (const c of overlap) competitorsCiting.add(c);
3542
3534
  }
3543
3535
  let category;
@@ -3570,8 +3562,7 @@ async function analyticsRoutes(app) {
3570
3562
  return reply.send({ cited, gap, uncited, runId: latestRun.id, window });
3571
3563
  });
3572
3564
  app.get("/projects/:name/analytics/sources", async (request, reply) => {
3573
- const project = resolveProjectSafe5(app, request.params.name, reply);
3574
- if (!project) return;
3565
+ const project = resolveProject(app.db, request.params.name);
3575
3566
  const window = parseWindow(request.query.window);
3576
3567
  const cutoff = windowCutoff(window);
3577
3568
  const windowRuns = app.db.select().from(runs).where(eq10(runs.projectId, project.id)).orderBy(desc3(runs.createdAt)).all().filter((r) => r.status === "completed" || r.status === "partial").filter((r) => !cutoff || r.createdAt >= cutoff);
@@ -3607,26 +3598,6 @@ async function analyticsRoutes(app) {
3607
3598
  return reply.send({ overall, byKeyword, runId: latestRunId, window });
3608
3599
  });
3609
3600
  }
3610
- function resolveProjectSafe5(app, name, reply) {
3611
- try {
3612
- return resolveProject(app.db, name);
3613
- } catch (e) {
3614
- if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
3615
- const err = e;
3616
- reply.status(err.statusCode).send(err.toJSON());
3617
- return null;
3618
- }
3619
- throw e;
3620
- }
3621
- }
3622
- function tryParseJson3(value, fallback) {
3623
- if (!value) return fallback;
3624
- try {
3625
- return JSON.parse(value);
3626
- } catch {
3627
- return fallback;
3628
- }
3629
- }
3630
3601
  var PROVIDER_INFRA_DOMAINS = /* @__PURE__ */ new Set([
3631
3602
  "vertexaisearch.cloud.google.com",
3632
3603
  "openai.com",
@@ -3644,7 +3615,7 @@ function isProviderInfraDomain(uri) {
3644
3615
  return false;
3645
3616
  }
3646
3617
  function parseGroundingSources(rawResponse) {
3647
- const parsed = tryParseJson3(rawResponse, {});
3618
+ const parsed = parseJsonColumn(rawResponse, {});
3648
3619
  const sources = parsed.groundingSources;
3649
3620
  if (!Array.isArray(sources)) return [];
3650
3621
  return sources.filter(
@@ -6014,34 +5985,29 @@ import crypto11 from "crypto";
6014
5985
  import { eq as eq11 } from "drizzle-orm";
6015
5986
  async function scheduleRoutes(app, opts) {
6016
5987
  app.put("/projects/:name/schedule", async (request, reply) => {
6017
- const project = resolveProjectSafe6(app, request.params.name, reply);
6018
- if (!project) return;
5988
+ const project = resolveProject(app.db, request.params.name);
6019
5989
  const parsedBody = scheduleUpsertRequestSchema.safeParse(request.body);
6020
5990
  if (!parsedBody.success) {
6021
- const err = validationError("Invalid schedule payload", {
5991
+ throw validationError("Invalid schedule payload", {
6022
5992
  issues: parsedBody.error.issues.map((issue) => ({
6023
5993
  path: issue.path.join("."),
6024
5994
  message: issue.message
6025
5995
  }))
6026
5996
  });
6027
- return reply.status(err.statusCode).send(err.toJSON());
6028
5997
  }
6029
5998
  const { preset, cron: cron2, timezone, providers, enabled } = parsedBody.data;
6030
5999
  const validNames = opts.validProviderNames ?? [];
6031
6000
  if (validNames.length && providers?.length) {
6032
6001
  const invalid = providers.filter((p) => !validNames.includes(p));
6033
6002
  if (invalid.length) {
6034
- const err = validationError(`Invalid provider(s): ${invalid.join(", ")}. Must be one of: ${validNames.join(", ")}`, {
6003
+ throw validationError(`Invalid provider(s): ${invalid.join(", ")}. Must be one of: ${validNames.join(", ")}`, {
6035
6004
  invalidProviders: invalid,
6036
6005
  validProviders: validNames
6037
6006
  });
6038
- return reply.status(err.statusCode).send(err.toJSON());
6039
6007
  }
6040
6008
  }
6041
6009
  if (!isValidTimezone(timezone)) {
6042
- return reply.status(400).send({
6043
- error: { code: "VALIDATION_ERROR", message: `Invalid timezone: ${timezone}` }
6044
- });
6010
+ throw validationError(`Invalid timezone: ${timezone}`);
6045
6011
  }
6046
6012
  let cronExpr;
6047
6013
  if (preset) {
@@ -6049,14 +6015,12 @@ async function scheduleRoutes(app, opts) {
6049
6015
  cronExpr = resolvePreset(preset);
6050
6016
  } catch (err) {
6051
6017
  const msg = err instanceof Error ? err.message : String(err);
6052
- return reply.status(400).send({ error: { code: "VALIDATION_ERROR", message: msg } });
6018
+ throw validationError(msg);
6053
6019
  }
6054
6020
  } else {
6055
6021
  cronExpr = cron2;
6056
6022
  if (!validateCron(cronExpr)) {
6057
- return reply.status(400).send({
6058
- error: { code: "VALIDATION_ERROR", message: `Invalid cron expression: ${cronExpr}` }
6059
- });
6023
+ throw validationError(`Invalid cron expression: ${cronExpr}`);
6060
6024
  }
6061
6025
  }
6062
6026
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -6096,20 +6060,18 @@ async function scheduleRoutes(app, opts) {
6096
6060
  return reply.status(existing ? 200 : 201).send(formatSchedule(schedule));
6097
6061
  });
6098
6062
  app.get("/projects/:name/schedule", async (request, reply) => {
6099
- const project = resolveProjectSafe6(app, request.params.name, reply);
6100
- if (!project) return;
6063
+ const project = resolveProject(app.db, request.params.name);
6101
6064
  const schedule = app.db.select().from(schedules).where(eq11(schedules.projectId, project.id)).get();
6102
6065
  if (!schedule) {
6103
- return reply.status(404).send({ error: { code: "NOT_FOUND", message: `No schedule for project '${request.params.name}'` } });
6066
+ throw notFound("Schedule", request.params.name);
6104
6067
  }
6105
6068
  return reply.send(formatSchedule(schedule));
6106
6069
  });
6107
6070
  app.delete("/projects/:name/schedule", async (request, reply) => {
6108
- const project = resolveProjectSafe6(app, request.params.name, reply);
6109
- if (!project) return;
6071
+ const project = resolveProject(app.db, request.params.name);
6110
6072
  const schedule = app.db.select().from(schedules).where(eq11(schedules.projectId, project.id)).get();
6111
6073
  if (!schedule) {
6112
- return reply.status(404).send({ error: { code: "NOT_FOUND", message: `No schedule for project '${request.params.name}'` } });
6074
+ throw notFound("Schedule", request.params.name);
6113
6075
  }
6114
6076
  app.db.delete(schedules).where(eq11(schedules.id, schedule.id)).run();
6115
6077
  writeAuditLog(app.db, {
@@ -6131,25 +6093,13 @@ function formatSchedule(row) {
6131
6093
  preset: row.preset,
6132
6094
  timezone: row.timezone,
6133
6095
  enabled: row.enabled === 1,
6134
- providers: JSON.parse(row.providers),
6096
+ providers: parseJsonColumn(row.providers, []),
6135
6097
  lastRunAt: row.lastRunAt,
6136
6098
  nextRunAt: row.nextRunAt,
6137
6099
  createdAt: row.createdAt,
6138
6100
  updatedAt: row.updatedAt
6139
6101
  };
6140
6102
  }
6141
- function resolveProjectSafe6(app, name, reply) {
6142
- try {
6143
- return resolveProject(app.db, name);
6144
- } catch (e) {
6145
- if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
6146
- const err = e;
6147
- reply.status(err.statusCode).send(err.toJSON());
6148
- return null;
6149
- }
6150
- throw e;
6151
- }
6152
- }
6153
6103
 
6154
6104
  // ../api-routes/src/notifications.ts
6155
6105
  import crypto12 from "crypto";
@@ -6160,30 +6110,15 @@ async function notificationRoutes(app) {
6160
6110
  return reply.send(VALID_EVENTS);
6161
6111
  });
6162
6112
  app.post("/projects/:name/notifications", async (request, reply) => {
6163
- const project = resolveProjectSafe7(app, request.params.name, reply);
6164
- if (!project) return;
6113
+ const project = resolveProject(app.db, request.params.name);
6165
6114
  const { channel, url, events } = request.body ?? {};
6166
- if (channel !== "webhook") {
6167
- return reply.status(400).send({
6168
- error: { code: "VALIDATION_ERROR", message: 'Only "webhook" channel is supported' }
6169
- });
6170
- }
6115
+ if (channel !== "webhook") throw validationError('Only "webhook" channel is supported');
6171
6116
  const urlCheck = await resolveWebhookTarget(url ?? "");
6172
- if (!urlCheck.ok) {
6173
- return reply.status(400).send({
6174
- error: { code: "VALIDATION_ERROR", message: urlCheck.message }
6175
- });
6176
- }
6177
- if (!events?.length) {
6178
- return reply.status(400).send({
6179
- error: { code: "VALIDATION_ERROR", message: '"events" must be a non-empty array' }
6180
- });
6181
- }
6117
+ if (!urlCheck.ok) throw validationError(urlCheck.message);
6118
+ if (!events?.length) throw validationError('"events" must be a non-empty array');
6182
6119
  const invalid = events.filter((e) => !VALID_EVENTS.includes(e));
6183
6120
  if (invalid.length) {
6184
- return reply.status(400).send({
6185
- error: { code: "VALIDATION_ERROR", message: `Invalid event(s): ${invalid.join(", ")}. Must be one of: ${VALID_EVENTS.join(", ")}` }
6186
- });
6121
+ throw validationError(`Invalid event(s): ${invalid.join(", ")}. Must be one of: ${VALID_EVENTS.join(", ")}`);
6187
6122
  }
6188
6123
  const now = (/* @__PURE__ */ new Date()).toISOString();
6189
6124
  const id = crypto12.randomUUID();
@@ -6212,19 +6147,15 @@ async function notificationRoutes(app) {
6212
6147
  });
6213
6148
  });
6214
6149
  app.get("/projects/:name/notifications", async (request, reply) => {
6215
- const project = resolveProjectSafe7(app, request.params.name, reply);
6216
- if (!project) return;
6150
+ const project = resolveProject(app.db, request.params.name);
6217
6151
  const rows = app.db.select().from(notifications).where(eq12(notifications.projectId, project.id)).all();
6218
6152
  return reply.send(rows.map(formatNotification));
6219
6153
  });
6220
6154
  app.delete("/projects/:name/notifications/:id", async (request, reply) => {
6221
- const project = resolveProjectSafe7(app, request.params.name, reply);
6222
- if (!project) return;
6155
+ const project = resolveProject(app.db, request.params.name);
6223
6156
  const notification = app.db.select().from(notifications).where(eq12(notifications.id, request.params.id)).get();
6224
6157
  if (!notification || notification.projectId !== project.id) {
6225
- return reply.status(404).send({
6226
- error: { code: "NOT_FOUND", message: `Notification '${request.params.id}' not found` }
6227
- });
6158
+ throw notFound("Notification", request.params.id);
6228
6159
  }
6229
6160
  app.db.delete(notifications).where(eq12(notifications.id, notification.id)).run();
6230
6161
  writeAuditLog(app.db, {
@@ -6237,21 +6168,14 @@ async function notificationRoutes(app) {
6237
6168
  return reply.status(204).send();
6238
6169
  });
6239
6170
  app.post("/projects/:name/notifications/:id/test", async (request, reply) => {
6240
- const project = resolveProjectSafe7(app, request.params.name, reply);
6241
- if (!project) return;
6171
+ const project = resolveProject(app.db, request.params.name);
6242
6172
  const notification = app.db.select().from(notifications).where(eq12(notifications.id, request.params.id)).get();
6243
6173
  if (!notification || notification.projectId !== project.id) {
6244
- return reply.status(404).send({
6245
- error: { code: "NOT_FOUND", message: `Notification '${request.params.id}' not found` }
6246
- });
6174
+ throw notFound("Notification", request.params.id);
6247
6175
  }
6248
- const config = JSON.parse(notification.config);
6176
+ const config = parseJsonColumn(notification.config, { url: "", events: [] });
6249
6177
  const urlCheck = await resolveWebhookTarget(config.url);
6250
- if (!urlCheck.ok) {
6251
- return reply.status(400).send({
6252
- error: { code: "VALIDATION_ERROR", message: `Stored webhook URL is invalid: ${urlCheck.message}` }
6253
- });
6254
- }
6178
+ if (!urlCheck.ok) throw validationError(`Stored webhook URL is invalid: ${urlCheck.message}`);
6255
6179
  const payload = {
6256
6180
  source: "canonry",
6257
6181
  event: "run.completed",
@@ -6274,14 +6198,12 @@ async function notificationRoutes(app) {
6274
6198
  entityId: notification.id,
6275
6199
  diff: { status, error }
6276
6200
  });
6277
- if (error) {
6278
- return reply.status(502).send({ error: { code: "DELIVERY_FAILED", message: error } });
6279
- }
6201
+ if (error) throw deliveryFailed(error);
6280
6202
  return reply.send({ status, ok: status >= 200 && status < 300 });
6281
6203
  });
6282
6204
  }
6283
6205
  function formatNotification(row) {
6284
- const config = JSON.parse(row.config);
6206
+ const config = parseJsonColumn(row.config, { url: "", events: [] });
6285
6207
  const redacted = redactNotificationUrl(config.url);
6286
6208
  return {
6287
6209
  id: row.id,
@@ -6296,22 +6218,10 @@ function formatNotification(row) {
6296
6218
  updatedAt: row.updatedAt
6297
6219
  };
6298
6220
  }
6299
- function resolveProjectSafe7(app, name, reply) {
6300
- try {
6301
- return resolveProject(app.db, name);
6302
- } catch (e) {
6303
- if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
6304
- const err = e;
6305
- reply.status(err.statusCode).send(err.toJSON());
6306
- return null;
6307
- }
6308
- throw e;
6309
- }
6310
- }
6311
6221
 
6312
6222
  // ../api-routes/src/google.ts
6313
6223
  import crypto13 from "crypto";
6314
- import { eq as eq13, and as and3, desc as desc4, sql as sql2 } from "drizzle-orm";
6224
+ import { eq as eq13, and as and2, desc as desc4, sql as sql3 } from "drizzle-orm";
6315
6225
 
6316
6226
  // ../integration-google/src/constants.ts
6317
6227
  var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
@@ -6758,11 +6668,11 @@ async function googleRoutes(app, opts) {
6758
6668
  const project = resolveProject(app.db, request.params.name);
6759
6669
  const { startDate, endDate, query, page, limit } = request.query;
6760
6670
  const conditions = [eq13(gscSearchData.projectId, project.id)];
6761
- if (startDate) conditions.push(sql2`${gscSearchData.date} >= ${startDate}`);
6762
- if (endDate) conditions.push(sql2`${gscSearchData.date} <= ${endDate}`);
6763
- if (query) conditions.push(sql2`${gscSearchData.query} LIKE ${"%" + query + "%"}`);
6764
- if (page) conditions.push(sql2`${gscSearchData.page} LIKE ${"%" + page + "%"}`);
6765
- const rows = app.db.select().from(gscSearchData).where(and3(...conditions)).orderBy(desc4(gscSearchData.date)).limit(parseInt(limit ?? "500", 10)).all();
6671
+ if (startDate) conditions.push(sql3`${gscSearchData.date} >= ${startDate}`);
6672
+ if (endDate) conditions.push(sql3`${gscSearchData.date} <= ${endDate}`);
6673
+ if (query) conditions.push(sql3`${gscSearchData.query} LIKE ${"%" + query + "%"}`);
6674
+ if (page) conditions.push(sql3`${gscSearchData.page} LIKE ${"%" + page + "%"}`);
6675
+ const rows = app.db.select().from(gscSearchData).where(and2(...conditions)).orderBy(desc4(gscSearchData.date)).limit(parseInt(limit ?? "500", 10)).all();
6766
6676
  return rows.map((r) => ({
6767
6677
  date: r.date,
6768
6678
  query: r.query,
@@ -6840,7 +6750,7 @@ async function googleRoutes(app, opts) {
6840
6750
  const { url, limit } = request.query;
6841
6751
  const conditions = [eq13(gscUrlInspections.projectId, project.id)];
6842
6752
  if (url) conditions.push(eq13(gscUrlInspections.url, url));
6843
- const rows = app.db.select().from(gscUrlInspections).where(and3(...conditions)).orderBy(desc4(gscUrlInspections.inspectedAt)).limit(parseInt(limit ?? "100", 10)).all();
6753
+ const rows = app.db.select().from(gscUrlInspections).where(and2(...conditions)).orderBy(desc4(gscUrlInspections.inspectedAt)).limit(parseInt(limit ?? "100", 10)).all();
6844
6754
  return rows.map((r) => ({
6845
6755
  id: r.id,
6846
6756
  url: r.url,
@@ -7212,7 +7122,7 @@ async function googleRoutes(app, opts) {
7212
7122
 
7213
7123
  // ../api-routes/src/bing.ts
7214
7124
  import crypto14 from "crypto";
7215
- import { eq as eq14, and as and4, desc as desc5 } from "drizzle-orm";
7125
+ import { eq as eq14, and as and3, desc as desc5 } from "drizzle-orm";
7216
7126
 
7217
7127
  // ../integration-bing/src/constants.ts
7218
7128
  var BING_WMT_API_BASE = "https://ssl.bing.com/webmaster/api.svc/json";
@@ -7511,7 +7421,7 @@ async function bingRoutes(app, opts) {
7511
7421
  if (!store) return;
7512
7422
  const project = resolveProject(app.db, request.params.name);
7513
7423
  const { url, limit } = request.query;
7514
- const whereClause = url ? and4(eq14(bingUrlInspections.projectId, project.id), eq14(bingUrlInspections.url, url)) : eq14(bingUrlInspections.projectId, project.id);
7424
+ const whereClause = url ? and3(eq14(bingUrlInspections.projectId, project.id), eq14(bingUrlInspections.url, url)) : eq14(bingUrlInspections.projectId, project.id);
7515
7425
  const filtered = app.db.select().from(bingUrlInspections).where(whereClause).orderBy(desc5(bingUrlInspections.inspectedAt)).limit(Math.max(1, Math.min(parseInt(limit ?? "100", 10) || 100, 1e3))).all();
7516
7426
  return filtered.map((r) => ({
7517
7427
  id: r.id,
@@ -7701,7 +7611,7 @@ async function bingRoutes(app, opts) {
7701
7611
  import fs2 from "fs";
7702
7612
  import path2 from "path";
7703
7613
  import os2 from "os";
7704
- import { eq as eq15, and as and5 } from "drizzle-orm";
7614
+ import { eq as eq15, and as and4 } from "drizzle-orm";
7705
7615
  function getScreenshotDir() {
7706
7616
  return path2.join(os2.homedir(), ".canonry", "screenshots");
7707
7617
  }
@@ -7774,7 +7684,7 @@ async function cdpRoutes(app, opts) {
7774
7684
  async (request, reply) => {
7775
7685
  const project = resolveProject(app.db, request.params.name);
7776
7686
  const { runId } = request.params;
7777
- const run = app.db.select().from(runs).where(and5(eq15(runs.id, runId), eq15(runs.projectId, project.id))).get();
7687
+ const run = app.db.select().from(runs).where(and4(eq15(runs.id, runId), eq15(runs.projectId, project.id))).get();
7778
7688
  if (!run) {
7779
7689
  const err = notFound("Run", runId);
7780
7690
  return reply.code(err.statusCode).send(err.toJSON());
@@ -7871,7 +7781,7 @@ async function cdpRoutes(app, opts) {
7871
7781
 
7872
7782
  // ../api-routes/src/ga.ts
7873
7783
  import crypto16 from "crypto";
7874
- import { eq as eq16, desc as desc6, and as and6, sql as sql3 } from "drizzle-orm";
7784
+ import { eq as eq16, desc as desc6, and as and5, sql as sql4 } from "drizzle-orm";
7875
7785
 
7876
7786
  // ../integration-google-analytics/src/ga4-client.ts
7877
7787
  import crypto15 from "crypto";
@@ -8351,10 +8261,10 @@ async function ga4Routes(app, opts) {
8351
8261
  const now = (/* @__PURE__ */ new Date()).toISOString();
8352
8262
  app.db.transaction((tx) => {
8353
8263
  tx.delete(gaTrafficSnapshots).where(
8354
- and6(
8264
+ and5(
8355
8265
  eq16(gaTrafficSnapshots.projectId, project.id),
8356
- sql3`${gaTrafficSnapshots.date} >= ${summary.periodStart}`,
8357
- sql3`${gaTrafficSnapshots.date} <= ${summary.periodEnd}`
8266
+ sql4`${gaTrafficSnapshots.date} >= ${summary.periodStart}`,
8267
+ sql4`${gaTrafficSnapshots.date} <= ${summary.periodEnd}`
8358
8268
  )
8359
8269
  ).run();
8360
8270
  if (rows.length > 0) {
@@ -8372,10 +8282,10 @@ async function ga4Routes(app, opts) {
8372
8282
  }
8373
8283
  }
8374
8284
  tx.delete(gaAiReferrals).where(
8375
- and6(
8285
+ and5(
8376
8286
  eq16(gaAiReferrals.projectId, project.id),
8377
- sql3`${gaAiReferrals.date} >= ${summary.periodStart}`,
8378
- sql3`${gaAiReferrals.date} <= ${summary.periodEnd}`
8287
+ sql4`${gaAiReferrals.date} >= ${summary.periodStart}`,
8288
+ sql4`${gaAiReferrals.date} <= ${summary.periodEnd}`
8379
8289
  )
8380
8290
  ).run();
8381
8291
  if (aiReferrals.length > 0) {
@@ -8436,16 +8346,16 @@ async function ga4Routes(app, opts) {
8436
8346
  }).from(gaTrafficSummaries).where(eq16(gaTrafficSummaries.projectId, project.id)).get();
8437
8347
  const rows = app.db.select({
8438
8348
  landingPage: gaTrafficSnapshots.landingPage,
8439
- sessions: sql3`SUM(${gaTrafficSnapshots.sessions})`,
8440
- organicSessions: sql3`SUM(${gaTrafficSnapshots.organicSessions})`,
8441
- users: sql3`SUM(${gaTrafficSnapshots.users})`
8442
- }).from(gaTrafficSnapshots).where(eq16(gaTrafficSnapshots.projectId, project.id)).groupBy(gaTrafficSnapshots.landingPage).orderBy(sql3`SUM(${gaTrafficSnapshots.sessions}) DESC`).limit(limit).all();
8349
+ sessions: sql4`SUM(${gaTrafficSnapshots.sessions})`,
8350
+ organicSessions: sql4`SUM(${gaTrafficSnapshots.organicSessions})`,
8351
+ users: sql4`SUM(${gaTrafficSnapshots.users})`
8352
+ }).from(gaTrafficSnapshots).where(eq16(gaTrafficSnapshots.projectId, project.id)).groupBy(gaTrafficSnapshots.landingPage).orderBy(sql4`SUM(${gaTrafficSnapshots.sessions}) DESC`).limit(limit).all();
8443
8353
  const aiReferrals = app.db.select({
8444
8354
  source: gaAiReferrals.source,
8445
8355
  medium: gaAiReferrals.medium,
8446
- sessions: sql3`SUM(${gaAiReferrals.sessions})`,
8447
- users: sql3`SUM(${gaAiReferrals.users})`
8448
- }).from(gaAiReferrals).where(eq16(gaAiReferrals.projectId, project.id)).groupBy(gaAiReferrals.source, gaAiReferrals.medium).orderBy(sql3`SUM(${gaAiReferrals.sessions}) DESC`).all();
8356
+ sessions: sql4`SUM(${gaAiReferrals.sessions})`,
8357
+ users: sql4`SUM(${gaAiReferrals.users})`
8358
+ }).from(gaAiReferrals).where(eq16(gaAiReferrals.projectId, project.id)).groupBy(gaAiReferrals.source, gaAiReferrals.medium).orderBy(sql4`SUM(${gaAiReferrals.sessions}) DESC`).all();
8449
8359
  const latestSync = app.db.select({ syncedAt: gaTrafficSummaries.syncedAt }).from(gaTrafficSummaries).where(eq16(gaTrafficSummaries.projectId, project.id)).orderBy(desc6(gaTrafficSummaries.syncedAt)).limit(1).get();
8450
8360
  return {
8451
8361
  totalSessions: summary?.totalSessions ?? 0,
@@ -8477,10 +8387,10 @@ async function ga4Routes(app, opts) {
8477
8387
  }
8478
8388
  const trafficPages = app.db.select({
8479
8389
  landingPage: gaTrafficSnapshots.landingPage,
8480
- sessions: sql3`SUM(${gaTrafficSnapshots.sessions})`,
8481
- organicSessions: sql3`SUM(${gaTrafficSnapshots.organicSessions})`,
8482
- users: sql3`SUM(${gaTrafficSnapshots.users})`
8483
- }).from(gaTrafficSnapshots).where(eq16(gaTrafficSnapshots.projectId, project.id)).groupBy(gaTrafficSnapshots.landingPage).orderBy(sql3`SUM(${gaTrafficSnapshots.sessions}) DESC`).all();
8390
+ sessions: sql4`SUM(${gaTrafficSnapshots.sessions})`,
8391
+ organicSessions: sql4`SUM(${gaTrafficSnapshots.organicSessions})`,
8392
+ users: sql4`SUM(${gaTrafficSnapshots.users})`
8393
+ }).from(gaTrafficSnapshots).where(eq16(gaTrafficSnapshots.projectId, project.id)).groupBy(gaTrafficSnapshots.landingPage).orderBy(sql4`SUM(${gaTrafficSnapshots.sessions}) DESC`).all();
8484
8394
  return {
8485
8395
  pages: trafficPages.map((r) => ({
8486
8396
  landingPage: r.landingPage,
@@ -9165,12 +9075,12 @@ var CANONRY_SCHEMA_START = "<!-- canonry:schema:start -->";
9165
9075
  var CANONRY_SCHEMA_END = "<!-- canonry:schema:end -->";
9166
9076
  function stripCanonrySchema(content) {
9167
9077
  const regex = new RegExp(
9168
- `${escapeRegExp(CANONRY_SCHEMA_START)}[\\s\\S]*?${escapeRegExp(CANONRY_SCHEMA_END)}`,
9078
+ `${escapeRegExp2(CANONRY_SCHEMA_START)}[\\s\\S]*?${escapeRegExp2(CANONRY_SCHEMA_END)}`,
9169
9079
  "g"
9170
9080
  );
9171
9081
  return content.replace(regex, "").replace(/\n{3,}/g, "\n\n").trim();
9172
9082
  }
9173
- function escapeRegExp(str) {
9083
+ function escapeRegExp2(str) {
9174
9084
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9175
9085
  }
9176
9086
  function injectCanonrySchema(content, schemas) {
@@ -9277,7 +9187,7 @@ async function getSchemaStatus(connection, env) {
9277
9187
  const thirdPartySchemas = [];
9278
9188
  if (hasCanonryMarker) {
9279
9189
  const markerRegex = new RegExp(
9280
- `${escapeRegExp(CANONRY_SCHEMA_START)}([\\s\\S]*?)${escapeRegExp(CANONRY_SCHEMA_END)}`
9190
+ `${escapeRegExp2(CANONRY_SCHEMA_START)}([\\s\\S]*?)${escapeRegExp2(CANONRY_SCHEMA_END)}`
9281
9191
  );
9282
9192
  const match = markerRegex.exec(rawContent);
9283
9193
  if (match?.[1]) {
@@ -12186,7 +12096,7 @@ import crypto18 from "crypto";
12186
12096
  import fs4 from "fs";
12187
12097
  import path5 from "path";
12188
12098
  import os4 from "os";
12189
- import { and as and7, eq as eq17, inArray as inArray3 } from "drizzle-orm";
12099
+ import { and as and6, eq as eq17, inArray as inArray3 } from "drizzle-orm";
12190
12100
 
12191
12101
  // src/logger.ts
12192
12102
  var IS_TTY = process.stdout.isTTY === true;
@@ -12336,7 +12246,7 @@ var JobRunner = class {
12336
12246
  throw new Error(`Run ${runId} is not executable from status '${existingRun.status}'`);
12337
12247
  }
12338
12248
  if (existingRun.status === "queued") {
12339
- this.db.update(runs).set({ status: "running", startedAt: now }).where(and7(eq17(runs.id, runId), eq17(runs.status, "queued"))).run();
12249
+ this.db.update(runs).set({ status: "running", startedAt: now }).where(and6(eq17(runs.id, runId), eq17(runs.status, "queued"))).run();
12340
12250
  }
12341
12251
  this.throwIfRunCancelled(runId);
12342
12252
  const project = this.db.select().from(projects).where(eq17(projects.id, projectId)).get();
@@ -12422,6 +12332,11 @@ var JobRunner = class {
12422
12332
  const normalized = adapter.normalizeResult(raw);
12423
12333
  log.info("query.result", { runId, provider: providerName, keyword: kw.keyword, citedDomains: normalized.citedDomains, groundingSources: normalized.groundingSources.map((s) => s.uri), matchDomains: allDomains });
12424
12334
  const citationState = determineCitationState(normalized, allDomains);
12335
+ const answerMentioned = determineAnswerMentioned(
12336
+ normalized.answerText,
12337
+ project.displayName,
12338
+ allDomains
12339
+ );
12425
12340
  const overlap = computeCompetitorOverlap(normalized, competitorDomains);
12426
12341
  const extractedCompetitors = extractRecommendedCompetitors(
12427
12342
  normalized.answerText,
@@ -12444,6 +12359,7 @@ var JobRunner = class {
12444
12359
  provider: providerName,
12445
12360
  model: raw.model,
12446
12361
  citationState,
12362
+ answerMentioned,
12447
12363
  answerText: normalized.answerText,
12448
12364
  citedDomains: JSON.stringify(normalized.citedDomains),
12449
12365
  competitorOverlap: JSON.stringify(overlap),
@@ -12466,6 +12382,7 @@ var JobRunner = class {
12466
12382
  provider: providerName,
12467
12383
  model: raw.model,
12468
12384
  citationState,
12385
+ answerMentioned,
12469
12386
  answerText: normalized.answerText,
12470
12387
  citedDomains: JSON.stringify(normalized.citedDomains),
12471
12388
  competitorOverlap: JSON.stringify(overlap),
@@ -12481,7 +12398,7 @@ var JobRunner = class {
12481
12398
  }).run();
12482
12399
  }
12483
12400
  totalSnapshotsInserted++;
12484
- log.info("query.citation", { runId, provider: providerName, keyword: kw.keyword, citationState });
12401
+ log.info("query.citation", { runId, provider: providerName, keyword: kw.keyword, citationState, answerMentioned });
12485
12402
  });
12486
12403
  } catch (err) {
12487
12404
  if (err instanceof RunCancelledError) {
@@ -12787,7 +12704,7 @@ function matchesBrandKey(candidateKey, brandKeys) {
12787
12704
 
12788
12705
  // src/gsc-sync.ts
12789
12706
  import crypto19 from "crypto";
12790
- import { eq as eq18, and as and8, sql as sql4 } from "drizzle-orm";
12707
+ import { eq as eq18, and as and7, sql as sql5 } from "drizzle-orm";
12791
12708
  var log2 = createLogger("GscSync");
12792
12709
  function formatDate2(d) {
12793
12710
  return d.toISOString().split("T")[0];
@@ -12839,10 +12756,10 @@ async function executeGscSync(db, runId, projectId, opts) {
12839
12756
  });
12840
12757
  log2.info("fetch.complete", { runId, projectId, rowCount: rows.length });
12841
12758
  db.delete(gscSearchData).where(
12842
- and8(
12759
+ and7(
12843
12760
  eq18(gscSearchData.projectId, projectId),
12844
- sql4`${gscSearchData.date} >= ${startDate}`,
12845
- sql4`${gscSearchData.date} <= ${endDate}`
12761
+ sql5`${gscSearchData.date} >= ${startDate}`,
12762
+ sql5`${gscSearchData.date} <= ${endDate}`
12846
12763
  )
12847
12764
  ).run();
12848
12765
  const batchSize = 500;
@@ -12928,7 +12845,7 @@ async function executeGscSync(db, runId, projectId, opts) {
12928
12845
  }
12929
12846
  }
12930
12847
  const snapshotDate = formatDate2(/* @__PURE__ */ new Date());
12931
- db.delete(gscCoverageSnapshots).where(and8(eq18(gscCoverageSnapshots.projectId, projectId), eq18(gscCoverageSnapshots.date, snapshotDate))).run();
12848
+ db.delete(gscCoverageSnapshots).where(and7(eq18(gscCoverageSnapshots.projectId, projectId), eq18(gscCoverageSnapshots.date, snapshotDate))).run();
12932
12849
  db.insert(gscCoverageSnapshots).values({
12933
12850
  id: crypto19.randomUUID(),
12934
12851
  projectId,
@@ -12951,7 +12868,7 @@ async function executeGscSync(db, runId, projectId, opts) {
12951
12868
 
12952
12869
  // src/gsc-inspect-sitemap.ts
12953
12870
  import crypto20 from "crypto";
12954
- import { eq as eq19, and as and9 } from "drizzle-orm";
12871
+ import { eq as eq19, and as and8 } from "drizzle-orm";
12955
12872
 
12956
12873
  // src/sitemap-parser.ts
12957
12874
  var LOC_REGEX = /<loc>\s*([^<]+?)\s*<\/loc>/gi;
@@ -13115,7 +13032,7 @@ async function executeInspectSitemap(db, runId, projectId, opts) {
13115
13032
  }
13116
13033
  }
13117
13034
  const snapshotDate = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
13118
- db.delete(gscCoverageSnapshots).where(and9(eq19(gscCoverageSnapshots.projectId, projectId), eq19(gscCoverageSnapshots.date, snapshotDate))).run();
13035
+ db.delete(gscCoverageSnapshots).where(and8(eq19(gscCoverageSnapshots.projectId, projectId), eq19(gscCoverageSnapshots.date, snapshotDate))).run();
13119
13036
  db.insert(gscCoverageSnapshots).values({
13120
13037
  id: crypto20.randomUUID(),
13121
13038
  projectId,
@@ -13308,7 +13225,7 @@ var Scheduler = class {
13308
13225
  };
13309
13226
 
13310
13227
  // src/notifier.ts
13311
- import { eq as eq21, desc as desc7, and as and10, or as or2 } from "drizzle-orm";
13228
+ import { eq as eq21, desc as desc7, and as and9, or as or2 } from "drizzle-orm";
13312
13229
  import crypto21 from "crypto";
13313
13230
  var log5 = createLogger("Notifier");
13314
13231
  var Notifier = class {
@@ -13372,7 +13289,7 @@ var Notifier = class {
13372
13289
  }
13373
13290
  computeTransitions(runId, projectId) {
13374
13291
  const recentRuns = this.db.select().from(runs).where(
13375
- and10(
13292
+ and9(
13376
13293
  eq21(runs.projectId, projectId),
13377
13294
  or2(eq21(runs.status, "completed"), eq21(runs.status, "partial"))
13378
13295
  )
@@ -13807,11 +13724,12 @@ var SnapshotService = class {
13807
13724
  manualCompetitors: ctx.manualCompetitors,
13808
13725
  targetDomain: ctx.domain
13809
13726
  });
13727
+ const answerVisibilityDomains = [ctx.domain];
13810
13728
  return {
13811
13729
  provider: provider.adapter.name,
13812
13730
  displayName: provider.adapter.displayName,
13813
13731
  model: raw.model,
13814
- mentioned: mentionsTargetCompany(normalized.answerText, ctx.companyName, ctx.domain),
13732
+ mentioned: determineAnswerMentioned(normalized.answerText, ctx.companyName, answerVisibilityDomains),
13815
13733
  cited: citesTargetDomain(normalized.citedDomains, normalized.groundingSources, ctx.domain),
13816
13734
  describedAccurately: "unknown",
13817
13735
  accuracyNotes: null,
@@ -14068,26 +13986,6 @@ function buildFallbackRecommendedActions(audit) {
14068
13986
  ];
14069
13987
  return uniqueStrings([...weakestFactors, ...defaults]).slice(0, 4);
14070
13988
  }
14071
- function mentionsTargetCompany(answerText, companyName, domain) {
14072
- const haystack = normalizeText(answerText);
14073
- if (!haystack) return false;
14074
- const fullName = normalizeText(companyName);
14075
- if (fullName && haystack.includes(fullName)) {
14076
- return true;
14077
- }
14078
- const targetTokens = uniqueStrings([
14079
- ...extractDistinctiveTokens(companyName),
14080
- ...extractDistinctiveTokens(extractHostname2(domain).split(".")[0] ?? "")
14081
- ]);
14082
- if (targetTokens.length === 0) return false;
14083
- let matches = 0;
14084
- for (const token of targetTokens) {
14085
- if (new RegExp(`\\b${escapeRegExp2(token)}\\b`, "i").test(answerText)) {
14086
- matches++;
14087
- }
14088
- }
14089
- return matches >= Math.min(2, targetTokens.length) || matches >= 1 && targetTokens.length === 1;
14090
- }
14091
13989
  function citesTargetDomain(citedDomains, groundingSources, targetDomain) {
14092
13990
  const normalizedTarget = extractHostname2(targetDomain);
14093
13991
  for (const domain of citedDomains) {
@@ -14158,9 +14056,6 @@ function parseJsonObject(input) {
14158
14056
  const json = start >= 0 && end >= start ? candidate.slice(start, end + 1) : candidate;
14159
14057
  return JSON.parse(json);
14160
14058
  }
14161
- function normalizeText(value) {
14162
- return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
14163
- }
14164
14059
  function normalizeStringList(values) {
14165
14060
  const items = values.flatMap((value) => value.split(","));
14166
14061
  return uniqueStrings(
@@ -14191,9 +14086,6 @@ function domainMatches2(candidate, target) {
14191
14086
  const normalizedTarget = normalizeDomain(target);
14192
14087
  return normalizedCandidate === normalizedTarget || normalizedCandidate.endsWith(`.${normalizedTarget}`);
14193
14088
  }
14194
- function extractDistinctiveTokens(value) {
14195
- return normalizeText(value).split(" ").filter((token) => token.length >= 4).filter((token) => !["llc", "inc", "corp", "company", "group", "services", "solutions", "agency"].includes(token));
14196
- }
14197
14089
  function isDomainLike(value) {
14198
14090
  const normalized = normalizeDomain(value);
14199
14091
  return normalized.includes(".") && !normalized.includes(" ");
@@ -14202,9 +14094,6 @@ function clipText(value, length) {
14202
14094
  if (value.length <= length) return value;
14203
14095
  return `${value.slice(0, length - 3)}...`;
14204
14096
  }
14205
- function escapeRegExp2(value) {
14206
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
14207
- }
14208
14097
 
14209
14098
  // src/server.ts
14210
14099
  var _require2 = createRequire2(import.meta.url);
@@ -14971,14 +14860,19 @@ export {
14971
14860
  isFirstRun,
14972
14861
  showFirstRunNotice,
14973
14862
  trackEvent,
14863
+ projects,
14864
+ runs,
14865
+ querySnapshots,
14866
+ apiKeys,
14867
+ createClient,
14868
+ parseJsonColumn,
14869
+ migrate,
14974
14870
  providerQuotaPolicySchema,
14975
14871
  resolveProviderInput,
14976
14872
  notificationEventSchema,
14977
14873
  effectiveDomains,
14874
+ determineAnswerMentioned,
14978
14875
  setGoogleAuthConfig,
14979
14876
  formatAuditFactorScore,
14980
- apiKeys,
14981
- createClient,
14982
- migrate,
14983
14877
  createServer
14984
14878
  };