@ainyc/canonry 1.29.0 → 1.31.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";
@@ -716,6 +719,49 @@ var wordpressAuditPageDtoSchema = z7.object({
716
719
  schemaPresent: z7.boolean(),
717
720
  issues: z7.array(wordpressAuditIssueDtoSchema).default([])
718
721
  });
722
+ var wordpressBulkMetaEntryResultDtoSchema = z7.object({
723
+ slug: z7.string(),
724
+ status: z7.enum(["applied", "skipped", "manual"]),
725
+ error: z7.string().optional(),
726
+ manualAssist: wordpressManualAssistDtoSchema.optional()
727
+ });
728
+ var wordpressBulkMetaResultDtoSchema = z7.object({
729
+ env: wordpressEnvSchema,
730
+ strategy: z7.enum(["plugin", "manual"]),
731
+ results: z7.array(wordpressBulkMetaEntryResultDtoSchema)
732
+ });
733
+ var wordpressSchemaDeployEntryResultDtoSchema = z7.object({
734
+ slug: z7.string(),
735
+ status: z7.enum(["deployed", "stripped", "skipped", "failed"]),
736
+ schemasInjected: z7.array(z7.string()).optional(),
737
+ manualAssist: wordpressManualAssistDtoSchema.optional(),
738
+ error: z7.string().optional()
739
+ });
740
+ var wordpressSchemaDeployResultDtoSchema = z7.object({
741
+ env: wordpressEnvSchema,
742
+ results: z7.array(wordpressSchemaDeployEntryResultDtoSchema)
743
+ });
744
+ var wordpressSchemaStatusPageDtoSchema = z7.object({
745
+ slug: z7.string(),
746
+ title: z7.string(),
747
+ canonrySchemas: z7.array(z7.string()),
748
+ thirdPartySchemas: z7.array(z7.string()),
749
+ hasCanonrySchema: z7.boolean()
750
+ });
751
+ var wordpressSchemaStatusResultDtoSchema = z7.object({
752
+ env: wordpressEnvSchema,
753
+ pages: z7.array(wordpressSchemaStatusPageDtoSchema)
754
+ });
755
+ var wordpressOnboardStepDtoSchema = z7.object({
756
+ name: z7.string(),
757
+ status: z7.enum(["completed", "skipped", "failed"]),
758
+ summary: z7.string().optional(),
759
+ error: z7.string().optional()
760
+ });
761
+ var wordpressOnboardResultDtoSchema = z7.object({
762
+ projectName: z7.string(),
763
+ steps: z7.array(wordpressOnboardStepDtoSchema)
764
+ });
719
765
  var wordpressDiffDtoSchema = z7.object({
720
766
  slug: z7.string(),
721
767
  live: wordpressDiffPageDtoSchema,
@@ -766,6 +812,7 @@ var querySnapshotDtoSchema = z8.object({
766
812
  answerText: z8.string().nullable().optional(),
767
813
  citedDomains: z8.array(z8.string()).default([]),
768
814
  competitorOverlap: z8.array(z8.string()).default([]),
815
+ recommendedCompetitors: z8.array(z8.string()).default([]),
769
816
  groundingSources: z8.array(groundingSourceSchema).default([]),
770
817
  searchQueries: z8.array(z8.string()).default([]),
771
818
  model: z8.string().nullable().optional(),
@@ -1004,6 +1051,12 @@ var ga4TrafficSnapshotDtoSchema = z11.object({
1004
1051
  organicSessions: z11.number(),
1005
1052
  users: z11.number()
1006
1053
  });
1054
+ var ga4AiReferralDtoSchema = z11.object({
1055
+ source: z11.string(),
1056
+ medium: z11.string(),
1057
+ sessions: z11.number(),
1058
+ users: z11.number()
1059
+ });
1007
1060
  var ga4TrafficSummaryDtoSchema = z11.object({
1008
1061
  totalSessions: z11.number(),
1009
1062
  totalOrganicSessions: z11.number(),
@@ -1014,6 +1067,7 @@ var ga4TrafficSummaryDtoSchema = z11.object({
1014
1067
  organicSessions: z11.number(),
1015
1068
  users: z11.number()
1016
1069
  })),
1070
+ aiReferrals: z11.array(ga4AiReferralDtoSchema),
1017
1071
  lastSyncedAt: z11.string().nullable()
1018
1072
  });
1019
1073
 
@@ -1036,6 +1090,7 @@ __export(schema_exports, {
1036
1090
  bingKeywordStats: () => bingKeywordStats,
1037
1091
  bingUrlInspections: () => bingUrlInspections,
1038
1092
  competitors: () => competitors,
1093
+ gaAiReferrals: () => gaAiReferrals,
1039
1094
  gaConnections: () => gaConnections,
1040
1095
  gaTrafficSnapshots: () => gaTrafficSnapshots,
1041
1096
  gaTrafficSummaries: () => gaTrafficSummaries,
@@ -1113,6 +1168,7 @@ var querySnapshots = sqliteTable("query_snapshots", {
1113
1168
  answerText: text("answer_text"),
1114
1169
  citedDomains: text("cited_domains").notNull().default("[]"),
1115
1170
  competitorOverlap: text("competitor_overlap").notNull().default("[]"),
1171
+ recommendedCompetitors: text("recommended_competitors").notNull().default("[]"),
1116
1172
  location: text("location"),
1117
1173
  screenshotPath: text("screenshot_path"),
1118
1174
  rawResponse: text("raw_response"),
@@ -1306,6 +1362,20 @@ var gaTrafficSnapshots = sqliteTable("ga_traffic_snapshots", {
1306
1362
  index("idx_ga_traffic_project_date").on(table.projectId, table.date),
1307
1363
  index("idx_ga_traffic_page").on(table.landingPage)
1308
1364
  ]);
1365
+ var gaAiReferrals = sqliteTable("ga_ai_referrals", {
1366
+ id: text("id").primaryKey(),
1367
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
1368
+ date: text("date").notNull(),
1369
+ source: text("source").notNull(),
1370
+ medium: text("medium").notNull(),
1371
+ sessions: integer("sessions").notNull().default(0),
1372
+ users: integer("users").notNull().default(0),
1373
+ syncedAt: text("synced_at").notNull()
1374
+ }, (table) => [
1375
+ index("idx_ga_ai_ref_project_date").on(table.projectId, table.date),
1376
+ index("idx_ga_ai_ref_source").on(table.source),
1377
+ uniqueIndex("idx_ga_ai_ref_unique").on(table.projectId, table.date, table.source, table.medium)
1378
+ ]);
1309
1379
  var gaTrafficSummaries = sqliteTable("ga_traffic_summaries", {
1310
1380
  id: text("id").primaryKey(),
1311
1381
  projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
@@ -1339,6 +1409,16 @@ function createClient(databasePath) {
1339
1409
  return drizzle(sqlite, { schema: schema_exports });
1340
1410
  }
1341
1411
 
1412
+ // ../db/src/json.ts
1413
+ function parseJsonColumn(value, fallback) {
1414
+ if (value == null || value === "") return fallback;
1415
+ try {
1416
+ return JSON.parse(value);
1417
+ } catch {
1418
+ return fallback;
1419
+ }
1420
+ }
1421
+
1342
1422
  // ../db/src/migrate.ts
1343
1423
  import { sql } from "drizzle-orm";
1344
1424
  var MIGRATION_SQL = `
@@ -1635,7 +1715,23 @@ var MIGRATIONS = [
1635
1715
  // v15: Bing URL inspections — document_size, anchor_count, discovery_date columns
1636
1716
  `ALTER TABLE bing_url_inspections ADD COLUMN document_size INTEGER`,
1637
1717
  `ALTER TABLE bing_url_inspections ADD COLUMN anchor_count INTEGER`,
1638
- `ALTER TABLE bing_url_inspections ADD COLUMN discovery_date TEXT`
1718
+ `ALTER TABLE bing_url_inspections ADD COLUMN discovery_date TEXT`,
1719
+ // v16: Recommended competitor names extracted from run answers
1720
+ `ALTER TABLE query_snapshots ADD COLUMN recommended_competitors TEXT NOT NULL DEFAULT '[]'`,
1721
+ // v17: GA4 AI referral tracking — ga_ai_referrals table
1722
+ `CREATE TABLE IF NOT EXISTS ga_ai_referrals (
1723
+ id TEXT PRIMARY KEY,
1724
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
1725
+ date TEXT NOT NULL,
1726
+ source TEXT NOT NULL,
1727
+ medium TEXT NOT NULL,
1728
+ sessions INTEGER NOT NULL DEFAULT 0,
1729
+ users INTEGER NOT NULL DEFAULT 0,
1730
+ synced_at TEXT NOT NULL
1731
+ )`,
1732
+ `CREATE INDEX IF NOT EXISTS idx_ga_ai_ref_project_date ON ga_ai_referrals(project_id, date)`,
1733
+ `CREATE INDEX IF NOT EXISTS idx_ga_ai_ref_source ON ga_ai_referrals(source)`,
1734
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_ga_ai_ref_unique ON ga_ai_referrals(project_id, date, source, medium)`
1639
1735
  ];
1640
1736
  function migrate(db) {
1641
1737
  const statements = MIGRATION_SQL.split(";").map((s) => s.trim()).filter((s) => s.length > 0);
@@ -1723,7 +1819,7 @@ import { eq as eq3 } from "drizzle-orm";
1723
1819
 
1724
1820
  // ../api-routes/src/helpers.ts
1725
1821
  import crypto3 from "crypto";
1726
- import { eq as eq2, and } from "drizzle-orm";
1822
+ import { eq as eq2, sql as sql2 } from "drizzle-orm";
1727
1823
  function resolveProject(db, name) {
1728
1824
  const project = db.select().from(projects).where(eq2(projects.name, name)).get();
1729
1825
  if (!project) {
@@ -1751,43 +1847,39 @@ async function projectRoutes(app, opts) {
1751
1847
  const { name } = request.params;
1752
1848
  const parsedBody = projectUpsertRequestSchema.safeParse(request.body);
1753
1849
  if (!parsedBody.success) {
1754
- const err = validationError("Invalid project payload", {
1850
+ throw validationError("Invalid project payload", {
1755
1851
  issues: parsedBody.error.issues.map((issue) => ({
1756
1852
  path: issue.path.join("."),
1757
1853
  message: issue.message
1758
1854
  }))
1759
1855
  });
1760
- return reply.status(err.statusCode).send(err.toJSON());
1761
1856
  }
1762
1857
  const body = parsedBody.data;
1763
1858
  const validNames = opts.validProviderNames ?? [];
1764
1859
  if (validNames.length && body.providers?.length) {
1765
1860
  const invalid = body.providers.filter((p) => !validNames.includes(p));
1766
1861
  if (invalid.length) {
1767
- const err = validationError(`Invalid provider(s): ${invalid.join(", ")}. Must be one of: ${validNames.join(", ")}`, {
1862
+ throw validationError(`Invalid provider(s): ${invalid.join(", ")}. Must be one of: ${validNames.join(", ")}`, {
1768
1863
  invalidProviders: invalid,
1769
1864
  validProviders: validNames
1770
1865
  });
1771
- return reply.status(err.statusCode).send(err.toJSON());
1772
1866
  }
1773
1867
  }
1774
1868
  const now = (/* @__PURE__ */ new Date()).toISOString();
1775
1869
  const existing = app.db.select().from(projects).where(eq3(projects.name, name)).get();
1776
- const existingLocations = existing ? JSON.parse(existing.locations || "[]") : [];
1870
+ const existingLocations = existing ? parseJsonColumn(existing.locations, []) : [];
1777
1871
  const nextLocations = body.locations ?? existingLocations;
1778
1872
  const duplicateLabels = findDuplicateLocationLabels(nextLocations);
1779
1873
  if (duplicateLabels.length > 0) {
1780
- const err = validationError(`Duplicate location labels are not allowed: ${duplicateLabels.join(", ")}`, {
1874
+ throw validationError(`Duplicate location labels are not allowed: ${duplicateLabels.join(", ")}`, {
1781
1875
  duplicateLabels
1782
1876
  });
1783
- return reply.status(err.statusCode).send(err.toJSON());
1784
1877
  }
1785
1878
  const nextDefaultLocation = body.defaultLocation !== void 0 ? body.defaultLocation ?? null : existing?.defaultLocation ?? null;
1786
1879
  if (!hasLocationLabel(nextLocations, nextDefaultLocation)) {
1787
- const err = validationError(`defaultLocation "${nextDefaultLocation}" must match a configured location label`, {
1880
+ throw validationError(`defaultLocation "${nextDefaultLocation}" must match a configured location label`, {
1788
1881
  defaultLocation: nextDefaultLocation
1789
1882
  });
1790
- return reply.status(err.statusCode).send(err.toJSON());
1791
1883
  }
1792
1884
  if (existing) {
1793
1885
  app.db.update(projects).set({
@@ -1849,28 +1941,11 @@ async function projectRoutes(app, opts) {
1849
1941
  return reply.send(rows.map(formatProject));
1850
1942
  });
1851
1943
  app.get("/projects/:name", async (request, reply) => {
1852
- try {
1853
- const project = resolveProject(app.db, request.params.name);
1854
- return reply.send(formatProject(project));
1855
- } catch (e) {
1856
- if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
1857
- const err = e;
1858
- return reply.status(err.statusCode).send(err.toJSON());
1859
- }
1860
- throw e;
1861
- }
1944
+ const project = resolveProject(app.db, request.params.name);
1945
+ return reply.send(formatProject(project));
1862
1946
  });
1863
1947
  app.delete("/projects/:name", async (request, reply) => {
1864
- let project;
1865
- try {
1866
- project = resolveProject(app.db, request.params.name);
1867
- } catch (e) {
1868
- if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
1869
- const err = e;
1870
- return reply.status(err.statusCode).send(err.toJSON());
1871
- }
1872
- throw e;
1873
- }
1948
+ const project = resolveProject(app.db, request.params.name);
1874
1949
  writeAuditLog(app.db, {
1875
1950
  projectId: project.id,
1876
1951
  actor: "api",
@@ -1883,26 +1958,15 @@ async function projectRoutes(app, opts) {
1883
1958
  return reply.status(204).send();
1884
1959
  });
1885
1960
  app.post("/projects/:name/locations", async (request, reply) => {
1886
- let project;
1887
- try {
1888
- project = resolveProject(app.db, request.params.name);
1889
- } catch (e) {
1890
- if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
1891
- const err = e;
1892
- return reply.status(err.statusCode).send(err.toJSON());
1893
- }
1894
- throw e;
1895
- }
1961
+ const project = resolveProject(app.db, request.params.name);
1896
1962
  const parsed = locationContextSchema.safeParse(request.body);
1897
1963
  if (!parsed.success) {
1898
- const err = validationError(parsed.error.issues.map((i) => i.message).join(", "));
1899
- return reply.status(err.statusCode).send(err.toJSON());
1964
+ throw validationError(parsed.error.issues.map((i) => i.message).join(", "));
1900
1965
  }
1901
1966
  const location = parsed.data;
1902
- const existing = JSON.parse(project.locations || "[]");
1967
+ const existing = parseJsonColumn(project.locations, []);
1903
1968
  if (existing.some((l) => l.label === location.label)) {
1904
- const err = validationError(`Location "${location.label}" already exists`);
1905
- return reply.status(err.statusCode).send(err.toJSON());
1969
+ throw validationError(`Location "${location.label}" already exists`);
1906
1970
  }
1907
1971
  existing.push(location);
1908
1972
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -1920,39 +1984,20 @@ async function projectRoutes(app, opts) {
1920
1984
  return reply.status(201).send(location);
1921
1985
  });
1922
1986
  app.get("/projects/:name/locations", async (request, reply) => {
1923
- let project;
1924
- try {
1925
- project = resolveProject(app.db, request.params.name);
1926
- } catch (e) {
1927
- if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
1928
- const err = e;
1929
- return reply.status(err.statusCode).send(err.toJSON());
1930
- }
1931
- throw e;
1932
- }
1933
- const locations = JSON.parse(project.locations || "[]");
1987
+ const project = resolveProject(app.db, request.params.name);
1988
+ const locations = parseJsonColumn(project.locations, []);
1934
1989
  return reply.send({
1935
1990
  locations,
1936
1991
  defaultLocation: project.defaultLocation
1937
1992
  });
1938
1993
  });
1939
1994
  app.delete("/projects/:name/locations/:label", async (request, reply) => {
1940
- let project;
1941
- try {
1942
- project = resolveProject(app.db, request.params.name);
1943
- } catch (e) {
1944
- if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
1945
- const err = e;
1946
- return reply.status(err.statusCode).send(err.toJSON());
1947
- }
1948
- throw e;
1949
- }
1995
+ const project = resolveProject(app.db, request.params.name);
1950
1996
  const label = decodeURIComponent(request.params.label);
1951
- const existing = JSON.parse(project.locations || "[]");
1997
+ const existing = parseJsonColumn(project.locations, []);
1952
1998
  const filtered = existing.filter((l) => l.label !== label);
1953
1999
  if (filtered.length === existing.length) {
1954
- const err = validationError(`Location "${label}" not found`);
1955
- return reply.status(err.statusCode).send(err.toJSON());
2000
+ throw validationError(`Location "${label}" not found`);
1956
2001
  }
1957
2002
  const now = (/* @__PURE__ */ new Date()).toISOString();
1958
2003
  const updates = {
@@ -1973,25 +2018,14 @@ async function projectRoutes(app, opts) {
1973
2018
  return reply.status(204).send();
1974
2019
  });
1975
2020
  app.put("/projects/:name/locations/default", async (request, reply) => {
1976
- let project;
1977
- try {
1978
- project = resolveProject(app.db, request.params.name);
1979
- } catch (e) {
1980
- if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
1981
- const err = e;
1982
- return reply.status(err.statusCode).send(err.toJSON());
1983
- }
1984
- throw e;
1985
- }
2021
+ const project = resolveProject(app.db, request.params.name);
1986
2022
  const label = request.body?.label;
1987
2023
  if (!label) {
1988
- const err = validationError("label is required");
1989
- return reply.status(err.statusCode).send(err.toJSON());
2024
+ throw validationError("label is required");
1990
2025
  }
1991
- const existing = JSON.parse(project.locations || "[]");
2026
+ const existing = parseJsonColumn(project.locations, []);
1992
2027
  if (!existing.some((l) => l.label === label)) {
1993
- const err = validationError(`Location "${label}" not found. Add it first.`);
1994
- return reply.status(err.statusCode).send(err.toJSON());
2028
+ throw validationError(`Location "${label}" not found. Add it first.`);
1995
2029
  }
1996
2030
  const now = (/* @__PURE__ */ new Date()).toISOString();
1997
2031
  app.db.update(projects).set({
@@ -2008,16 +2042,7 @@ async function projectRoutes(app, opts) {
2008
2042
  return reply.send({ defaultLocation: label });
2009
2043
  });
2010
2044
  app.get("/projects/:name/export", async (request, reply) => {
2011
- let project;
2012
- try {
2013
- project = resolveProject(app.db, request.params.name);
2014
- } catch (e) {
2015
- if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
2016
- const err = e;
2017
- return reply.status(err.statusCode).send(err.toJSON());
2018
- }
2019
- throw e;
2020
- }
2045
+ const project = resolveProject(app.db, request.params.name);
2021
2046
  const kws = app.db.select().from(keywords).where(eq3(keywords.projectId, project.id)).all();
2022
2047
  const comps = app.db.select().from(competitors).where(eq3(competitors.projectId, project.id)).all();
2023
2048
  const schedule = app.db.select().from(schedules).where(eq3(schedules.projectId, project.id)).get();
@@ -2027,21 +2052,21 @@ async function projectRoutes(app, opts) {
2027
2052
  kind: "Project",
2028
2053
  metadata: {
2029
2054
  name: project.name,
2030
- labels: JSON.parse(project.labels)
2055
+ labels: parseJsonColumn(project.labels, {})
2031
2056
  },
2032
2057
  spec: {
2033
2058
  displayName: project.displayName,
2034
2059
  canonicalDomain: project.canonicalDomain,
2035
- ownedDomains: JSON.parse(project.ownedDomains || "[]"),
2060
+ ownedDomains: parseJsonColumn(project.ownedDomains, []),
2036
2061
  country: project.country,
2037
2062
  language: project.language,
2038
2063
  keywords: kws.map((k) => k.keyword),
2039
2064
  competitors: comps.map((c) => c.domain),
2040
- providers: JSON.parse(project.providers || "[]"),
2041
- locations: JSON.parse(project.locations || "[]"),
2065
+ providers: parseJsonColumn(project.providers, []),
2066
+ locations: parseJsonColumn(project.locations, []),
2042
2067
  ...project.defaultLocation ? { defaultLocation: project.defaultLocation } : {},
2043
2068
  notifications: notificationRows.map((row) => {
2044
- const cfg = JSON.parse(row.config);
2069
+ const cfg = parseJsonColumn(row.config, { url: "", events: [] });
2045
2070
  return {
2046
2071
  channel: row.channel,
2047
2072
  url: cfg.url,
@@ -2052,7 +2077,7 @@ async function projectRoutes(app, opts) {
2052
2077
  schedule: {
2053
2078
  ...schedule.preset ? { preset: schedule.preset } : { cron: schedule.cronExpr },
2054
2079
  timezone: schedule.timezone,
2055
- providers: JSON.parse(schedule.providers || "[]")
2080
+ providers: parseJsonColumn(schedule.providers, [])
2056
2081
  }
2057
2082
  } : {}
2058
2083
  }
@@ -2066,13 +2091,13 @@ function formatProject(row) {
2066
2091
  name: row.name,
2067
2092
  displayName: row.displayName,
2068
2093
  canonicalDomain: row.canonicalDomain,
2069
- ownedDomains: JSON.parse(row.ownedDomains || "[]"),
2094
+ ownedDomains: parseJsonColumn(row.ownedDomains, []),
2070
2095
  country: row.country,
2071
2096
  language: row.language,
2072
- tags: JSON.parse(row.tags),
2073
- labels: JSON.parse(row.labels),
2074
- providers: JSON.parse(row.providers || "[]"),
2075
- locations: JSON.parse(row.locations || "[]"),
2097
+ tags: parseJsonColumn(row.tags, []),
2098
+ labels: parseJsonColumn(row.labels, {}),
2099
+ providers: parseJsonColumn(row.providers, []),
2100
+ locations: parseJsonColumn(row.locations, []),
2076
2101
  defaultLocation: row.defaultLocation,
2077
2102
  configSource: row.configSource,
2078
2103
  configRevision: row.configRevision,
@@ -2086,18 +2111,15 @@ import crypto5 from "crypto";
2086
2111
  import { eq as eq4 } from "drizzle-orm";
2087
2112
  async function keywordRoutes(app, opts) {
2088
2113
  app.get("/projects/:name/keywords", async (request, reply) => {
2089
- const project = resolveProjectSafe(app, request.params.name, reply);
2090
- if (!project) return;
2114
+ const project = resolveProject(app.db, request.params.name);
2091
2115
  const rows = app.db.select().from(keywords).where(eq4(keywords.projectId, project.id)).all();
2092
2116
  return reply.send(rows.map((r) => ({ id: r.id, keyword: r.keyword, createdAt: r.createdAt })));
2093
2117
  });
2094
2118
  app.put("/projects/:name/keywords", async (request, reply) => {
2095
- const project = resolveProjectSafe(app, request.params.name, reply);
2096
- if (!project) return;
2119
+ const project = resolveProject(app.db, request.params.name);
2097
2120
  const body = request.body;
2098
2121
  if (!body || !Array.isArray(body.keywords)) {
2099
- const err = validationError('Body must contain a "keywords" array');
2100
- return reply.status(err.statusCode).send(err.toJSON());
2122
+ throw validationError('Body must contain a "keywords" array');
2101
2123
  }
2102
2124
  const now = (/* @__PURE__ */ new Date()).toISOString();
2103
2125
  app.db.transaction((tx) => {
@@ -2122,12 +2144,10 @@ async function keywordRoutes(app, opts) {
2122
2144
  return reply.send(rows.map((r) => ({ id: r.id, keyword: r.keyword, createdAt: r.createdAt })));
2123
2145
  });
2124
2146
  app.delete("/projects/:name/keywords", async (request, reply) => {
2125
- const project = resolveProjectSafe(app, request.params.name, reply);
2126
- if (!project) return;
2147
+ const project = resolveProject(app.db, request.params.name);
2127
2148
  const body = request.body;
2128
2149
  if (!body || !Array.isArray(body.keywords) || body.keywords.length === 0) {
2129
- const err = validationError('Body must contain a non-empty "keywords" array');
2130
- return reply.status(err.statusCode).send(err.toJSON());
2150
+ throw validationError('Body must contain a non-empty "keywords" array');
2131
2151
  }
2132
2152
  const existing = app.db.select().from(keywords).where(eq4(keywords.projectId, project.id)).all();
2133
2153
  const toDelete = new Set(body.keywords);
@@ -2150,12 +2170,10 @@ async function keywordRoutes(app, opts) {
2150
2170
  return reply.send(rows.map((r) => ({ id: r.id, keyword: r.keyword, createdAt: r.createdAt })));
2151
2171
  });
2152
2172
  app.post("/projects/:name/keywords", async (request, reply) => {
2153
- const project = resolveProjectSafe(app, request.params.name, reply);
2154
- if (!project) return;
2173
+ const project = resolveProject(app.db, request.params.name);
2155
2174
  const body = request.body;
2156
2175
  if (!body || !Array.isArray(body.keywords)) {
2157
- const err = validationError('Body must contain a "keywords" array');
2158
- return reply.status(err.statusCode).send(err.toJSON());
2176
+ throw validationError('Body must contain a "keywords" array');
2159
2177
  }
2160
2178
  const now = (/* @__PURE__ */ new Date()).toISOString();
2161
2179
  const existing = app.db.select().from(keywords).where(eq4(keywords.projectId, project.id)).all();
@@ -2186,30 +2204,25 @@ async function keywordRoutes(app, opts) {
2186
2204
  return reply.send(rows.map((r) => ({ id: r.id, keyword: r.keyword, createdAt: r.createdAt })));
2187
2205
  });
2188
2206
  app.post("/projects/:name/keywords/generate", async (request, reply) => {
2189
- const project = resolveProjectSafe(app, request.params.name, reply);
2190
- if (!project) return;
2207
+ const project = resolveProject(app.db, request.params.name);
2191
2208
  const body = request.body;
2192
2209
  if (!body?.provider || typeof body.provider !== "string") {
2193
- const err = validationError('Body must contain a "provider" string');
2194
- return reply.status(err.statusCode).send(err.toJSON());
2210
+ throw validationError('Body must contain a "provider" string');
2195
2211
  }
2196
2212
  const provider = body.provider.trim().toLowerCase();
2197
2213
  const validNames = opts.validProviderNames ?? [];
2198
2214
  if (validNames.length && !validNames.includes(provider)) {
2199
- const err = validationError(`Unknown provider "${body.provider}". Valid providers: ${validNames.join(", ")}`, {
2215
+ throw validationError(`Unknown provider "${body.provider}". Valid providers: ${validNames.join(", ")}`, {
2200
2216
  provider: body.provider,
2201
2217
  validProviders: validNames
2202
2218
  });
2203
- return reply.status(err.statusCode).send(err.toJSON());
2204
2219
  }
2205
2220
  if (body.count !== void 0 && (typeof body.count !== "number" || !Number.isFinite(body.count) || !Number.isInteger(body.count))) {
2206
- const err = validationError('"count" must be an integer');
2207
- return reply.status(err.statusCode).send(err.toJSON());
2221
+ throw validationError('"count" must be an integer');
2208
2222
  }
2209
2223
  const count = Math.min(Math.max(body.count ?? 5, 1), 20);
2210
2224
  if (!opts.onGenerateKeywords) {
2211
- const err = notImplemented("Key phrase generation is not supported in this deployment");
2212
- return reply.status(err.statusCode).send(err.toJSON());
2225
+ throw notImplemented("Key phrase generation is not supported in this deployment");
2213
2226
  }
2214
2227
  const existingRows = app.db.select().from(keywords).where(eq4(keywords.projectId, project.id)).all();
2215
2228
  const existingKeywords = existingRows.map((r) => r.keyword);
@@ -2233,36 +2246,21 @@ async function keywordRoutes(app, opts) {
2233
2246
  }
2234
2247
  });
2235
2248
  }
2236
- function resolveProjectSafe(app, name, reply) {
2237
- try {
2238
- return resolveProject(app.db, name);
2239
- } catch (e) {
2240
- if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
2241
- const err = e;
2242
- reply.status(err.statusCode).send(err.toJSON());
2243
- return null;
2244
- }
2245
- throw e;
2246
- }
2247
- }
2248
2249
 
2249
2250
  // ../api-routes/src/competitors.ts
2250
2251
  import crypto6 from "crypto";
2251
2252
  import { eq as eq5 } from "drizzle-orm";
2252
2253
  async function competitorRoutes(app) {
2253
2254
  app.get("/projects/:name/competitors", async (request, reply) => {
2254
- const project = resolveProjectSafe2(app, request.params.name, reply);
2255
- if (!project) return;
2255
+ const project = resolveProject(app.db, request.params.name);
2256
2256
  const rows = app.db.select().from(competitors).where(eq5(competitors.projectId, project.id)).all();
2257
2257
  return reply.send(rows.map((r) => ({ id: r.id, domain: r.domain, createdAt: r.createdAt })));
2258
2258
  });
2259
2259
  app.put("/projects/:name/competitors", async (request, reply) => {
2260
- const project = resolveProjectSafe2(app, request.params.name, reply);
2261
- if (!project) return;
2260
+ const project = resolveProject(app.db, request.params.name);
2262
2261
  const body = request.body;
2263
2262
  if (!body || !Array.isArray(body.competitors)) {
2264
- const err = validationError('Body must contain a "competitors" array');
2265
- return reply.status(err.statusCode).send(err.toJSON());
2263
+ throw validationError('Body must contain a "competitors" array');
2266
2264
  }
2267
2265
  const now = (/* @__PURE__ */ new Date()).toISOString();
2268
2266
  app.db.transaction((tx) => {
@@ -2287,18 +2285,6 @@ async function competitorRoutes(app) {
2287
2285
  return reply.send(rows.map((r) => ({ id: r.id, domain: r.domain, createdAt: r.createdAt })));
2288
2286
  });
2289
2287
  }
2290
- function resolveProjectSafe2(app, name, reply) {
2291
- try {
2292
- return resolveProject(app.db, name);
2293
- } catch (e) {
2294
- if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
2295
- const err = e;
2296
- reply.status(err.statusCode).send(err.toJSON());
2297
- return null;
2298
- }
2299
- throw e;
2300
- }
2301
- }
2302
2288
 
2303
2289
  // ../api-routes/src/runs.ts
2304
2290
  import crypto8 from "crypto";
@@ -2306,7 +2292,7 @@ import { eq as eq7, asc, desc } from "drizzle-orm";
2306
2292
 
2307
2293
  // ../api-routes/src/run-queue.ts
2308
2294
  import crypto7 from "crypto";
2309
- import { and as and2, eq as eq6, or } from "drizzle-orm";
2295
+ import { and, eq as eq6, or } from "drizzle-orm";
2310
2296
  function queueRunIfProjectIdle(db, params) {
2311
2297
  const createdAt = params.createdAt ?? (/* @__PURE__ */ new Date()).toISOString();
2312
2298
  const kind = params.kind ?? "answer-visibility";
@@ -2314,7 +2300,7 @@ function queueRunIfProjectIdle(db, params) {
2314
2300
  const runId = crypto7.randomUUID();
2315
2301
  return db.transaction((tx) => {
2316
2302
  const activeRun = tx.select().from(runs).where(
2317
- and2(
2303
+ and(
2318
2304
  eq6(runs.projectId, params.projectId),
2319
2305
  or(eq6(runs.status, "queued"), eq6(runs.status, "running"))
2320
2306
  )
@@ -2338,13 +2324,9 @@ function queueRunIfProjectIdle(db, params) {
2338
2324
  // ../api-routes/src/runs.ts
2339
2325
  async function runRoutes(app, opts) {
2340
2326
  app.post("/projects/:name/runs", async (request, reply) => {
2341
- const project = resolveProjectSafe3(app, request.params.name, reply);
2342
- if (!project) return;
2327
+ const project = resolveProject(app.db, request.params.name);
2343
2328
  const kind = request.body?.kind ?? "answer-visibility";
2344
- if (kind !== "answer-visibility") {
2345
- const err = unsupportedKind(kind);
2346
- return reply.status(err.statusCode).send(err.toJSON());
2347
- }
2329
+ if (kind !== "answer-visibility") throw unsupportedKind(kind);
2348
2330
  const now = (/* @__PURE__ */ new Date()).toISOString();
2349
2331
  const trigger = request.body?.trigger ?? "manual";
2350
2332
  const rawProviders = request.body?.providers;
@@ -2354,31 +2336,30 @@ async function runRoutes(app, opts) {
2354
2336
  if (validNames.length) {
2355
2337
  const invalid = normalized.filter((p) => !validNames.includes(p));
2356
2338
  if (invalid.length) {
2357
- const err = validationError(`Invalid provider(s): ${invalid.join(", ")}. Must be one of: ${validNames.join(", ")}`, {
2339
+ throw validationError(`Invalid provider(s): ${invalid.join(", ")}. Must be one of: ${validNames.join(", ")}`, {
2358
2340
  invalidProviders: invalid,
2359
2341
  validProviders: validNames
2360
2342
  });
2361
- return reply.status(err.statusCode).send(err.toJSON());
2362
2343
  }
2363
2344
  }
2364
2345
  rawProviders.splice(0, rawProviders.length, ...normalized);
2365
2346
  }
2366
2347
  const providers = rawProviders?.length ? rawProviders : void 0;
2367
2348
  let resolvedLocation;
2368
- const projectLocations = JSON.parse(project.locations || "[]");
2349
+ const projectLocations = parseJsonColumn(project.locations, []);
2369
2350
  if (request.body?.noLocation) {
2370
2351
  resolvedLocation = null;
2371
2352
  } else if (request.body?.allLocations) {
2372
2353
  } else if (request.body?.location) {
2373
2354
  const loc = projectLocations.find((l) => l.label === request.body.location);
2374
2355
  if (!loc) {
2375
- return reply.status(400).send({ error: { code: "VALIDATION_ERROR", message: `Location "${request.body.location}" not found. Configure it first.` } });
2356
+ throw validationError(`Location "${request.body.location}" not found. Configure it first.`);
2376
2357
  }
2377
2358
  resolvedLocation = loc;
2378
2359
  }
2379
2360
  if (request.body?.allLocations) {
2380
2361
  if (projectLocations.length === 0) {
2381
- return reply.status(400).send({ error: { code: "VALIDATION_ERROR", message: "No locations configured for this project" } });
2362
+ throw validationError("No locations configured for this project");
2382
2363
  }
2383
2364
  const newRuns = [];
2384
2365
  for (const loc of projectLocations) {
@@ -2419,10 +2400,7 @@ async function runRoutes(app, opts) {
2419
2400
  trigger,
2420
2401
  location: locationLabel
2421
2402
  });
2422
- if (queueResult.conflict) {
2423
- const err = runInProgress(project.name);
2424
- return reply.status(err.statusCode).send(err.toJSON());
2425
- }
2403
+ if (queueResult.conflict) throw runInProgress(project.name);
2426
2404
  const runId = queueResult.runId;
2427
2405
  writeAuditLog(app.db, {
2428
2406
  projectId: project.id,
@@ -2438,8 +2416,7 @@ async function runRoutes(app, opts) {
2438
2416
  return reply.status(201).send(formatRun(run));
2439
2417
  });
2440
2418
  app.get("/projects/:name/runs", async (request, reply) => {
2441
- const project = resolveProjectSafe3(app, request.params.name, reply);
2442
- if (!project) return;
2419
+ const project = resolveProject(app.db, request.params.name);
2443
2420
  const parsedLimit = parseInt(request.query.limit ?? "", 10);
2444
2421
  const limit = Number.isNaN(parsedLimit) || parsedLimit <= 0 ? void 0 : parsedLimit;
2445
2422
  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();
@@ -2455,10 +2432,7 @@ async function runRoutes(app, opts) {
2455
2432
  return reply.status(207).send([]);
2456
2433
  }
2457
2434
  const kind = request.body?.kind ?? "answer-visibility";
2458
- if (kind !== "answer-visibility") {
2459
- const err = unsupportedKind(kind);
2460
- return reply.status(err.statusCode).send(err.toJSON());
2461
- }
2435
+ if (kind !== "answer-visibility") throw unsupportedKind(kind);
2462
2436
  const rawProviders = request.body?.providers;
2463
2437
  if (rawProviders?.length) {
2464
2438
  const normalized = rawProviders.map((p) => p.trim().toLowerCase()).filter(Boolean);
@@ -2466,11 +2440,10 @@ async function runRoutes(app, opts) {
2466
2440
  if (validNames.length) {
2467
2441
  const invalid = normalized.filter((p) => !validNames.includes(p));
2468
2442
  if (invalid.length) {
2469
- const err = validationError(`Invalid provider(s): ${invalid.join(", ")}. Must be one of: ${validNames.join(", ")}`, {
2443
+ throw validationError(`Invalid provider(s): ${invalid.join(", ")}. Must be one of: ${validNames.join(", ")}`, {
2470
2444
  invalidProviders: invalid,
2471
2445
  validProviders: validNames
2472
2446
  });
2473
- return reply.status(err.statusCode).send(err.toJSON());
2474
2447
  }
2475
2448
  }
2476
2449
  rawProviders.splice(0, rawProviders.length, ...normalized);
@@ -2507,15 +2480,9 @@ async function runRoutes(app, opts) {
2507
2480
  });
2508
2481
  app.post("/runs/:id/cancel", async (request, reply) => {
2509
2482
  const run = app.db.select().from(runs).where(eq7(runs.id, request.params.id)).get();
2510
- if (!run) {
2511
- const err = notFound("Run", request.params.id);
2512
- return reply.status(err.statusCode).send(err.toJSON());
2513
- }
2483
+ if (!run) throw notFound("Run", request.params.id);
2514
2484
  const terminalStatuses = /* @__PURE__ */ new Set(["completed", "partial", "failed", "cancelled"]);
2515
- if (terminalStatuses.has(run.status)) {
2516
- const err = runNotCancellable(run.id, run.status);
2517
- return reply.status(err.statusCode).send(err.toJSON());
2518
- }
2485
+ if (terminalStatuses.has(run.status)) throw runNotCancellable(run.id, run.status);
2519
2486
  const now = (/* @__PURE__ */ new Date()).toISOString();
2520
2487
  app.db.update(runs).set({ status: "cancelled", finishedAt: now, error: "Cancelled by user" }).where(eq7(runs.id, run.id)).run();
2521
2488
  writeAuditLog(app.db, {
@@ -2530,9 +2497,7 @@ async function runRoutes(app, opts) {
2530
2497
  });
2531
2498
  app.get("/runs/:id", async (request, reply) => {
2532
2499
  const run = app.db.select().from(runs).where(eq7(runs.id, request.params.id)).get();
2533
- if (!run) {
2534
- return reply.status(404).send({ error: { code: "NOT_FOUND", message: `Run '${request.params.id}' not found` } });
2535
- }
2500
+ if (!run) throw notFound("Run", request.params.id);
2536
2501
  const snapshots = app.db.select({
2537
2502
  id: querySnapshots.id,
2538
2503
  runId: querySnapshots.runId,
@@ -2544,6 +2509,7 @@ async function runRoutes(app, opts) {
2544
2509
  answerText: querySnapshots.answerText,
2545
2510
  citedDomains: querySnapshots.citedDomains,
2546
2511
  competitorOverlap: querySnapshots.competitorOverlap,
2512
+ recommendedCompetitors: querySnapshots.recommendedCompetitors,
2547
2513
  location: querySnapshots.location,
2548
2514
  rawResponse: querySnapshots.rawResponse,
2549
2515
  createdAt: querySnapshots.createdAt
@@ -2560,8 +2526,9 @@ async function runRoutes(app, opts) {
2560
2526
  provider: s.provider,
2561
2527
  citationState: s.citationState,
2562
2528
  answerText: s.answerText,
2563
- citedDomains: tryParseJson(s.citedDomains, []),
2564
- competitorOverlap: tryParseJson(s.competitorOverlap, []),
2529
+ citedDomains: parseJsonColumn(s.citedDomains, []),
2530
+ competitorOverlap: parseJsonColumn(s.competitorOverlap, []),
2531
+ recommendedCompetitors: parseJsonColumn(s.recommendedCompetitors, []),
2565
2532
  model: s.model ?? rawParsed.model,
2566
2533
  location: s.location,
2567
2534
  groundingSources: rawParsed.groundingSources,
@@ -2587,32 +2554,13 @@ function formatRun(row) {
2587
2554
  };
2588
2555
  }
2589
2556
  function parseSnapshotRawResponse(raw) {
2590
- const parsed = tryParseJson(raw ?? "{}", {});
2557
+ const parsed = parseJsonColumn(raw, {});
2591
2558
  return {
2592
2559
  groundingSources: parsed.groundingSources ?? [],
2593
2560
  searchQueries: parsed.searchQueries ?? [],
2594
2561
  model: parsed.model ?? null
2595
2562
  };
2596
2563
  }
2597
- function tryParseJson(value, fallback) {
2598
- try {
2599
- return JSON.parse(value);
2600
- } catch {
2601
- return fallback;
2602
- }
2603
- }
2604
- function resolveProjectSafe3(app, name, reply) {
2605
- try {
2606
- return resolveProject(app.db, name);
2607
- } catch (e) {
2608
- if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
2609
- const err = e;
2610
- reply.status(err.statusCode).send(err.toJSON());
2611
- return null;
2612
- }
2613
- throw e;
2614
- }
2615
- }
2616
2564
 
2617
2565
  // ../api-routes/src/apply.ts
2618
2566
  import crypto10 from "crypto";
@@ -2870,10 +2818,9 @@ async function applyRoutes(app, opts) {
2870
2818
  app.post("/apply", async (request, reply) => {
2871
2819
  const parsed = projectConfigSchema.safeParse(request.body);
2872
2820
  if (!parsed.success) {
2873
- const err = validationError("Invalid project config", {
2821
+ throw validationError("Invalid project config", {
2874
2822
  issues: parsed.error.issues.map((i) => ({ path: i.path.join("."), message: i.message }))
2875
2823
  });
2876
- return reply.status(err.statusCode).send(err.toJSON());
2877
2824
  }
2878
2825
  const config = parsed.data;
2879
2826
  const validNames = opts?.validProviderNames ?? [];
@@ -2885,70 +2832,104 @@ async function applyRoutes(app, opts) {
2885
2832
  if (allProviders.length) {
2886
2833
  const invalid = allProviders.filter((p) => !validNames.includes(p));
2887
2834
  if (invalid.length) {
2888
- const err = validationError(`Invalid provider(s): ${[...new Set(invalid)].join(", ")}. Must be one of: ${validNames.join(", ")}`, {
2835
+ throw validationError(`Invalid provider(s): ${[...new Set(invalid)].join(", ")}. Must be one of: ${validNames.join(", ")}`, {
2889
2836
  invalidProviders: [...new Set(invalid)],
2890
2837
  validProviders: validNames
2891
2838
  });
2892
- return reply.status(err.statusCode).send(err.toJSON());
2893
2839
  }
2894
2840
  }
2895
2841
  }
2842
+ let resolvedSchedule = null;
2843
+ let deleteSchedule = false;
2844
+ if (config.spec.schedule) {
2845
+ const schedSpec = config.spec.schedule;
2846
+ let cronExpr;
2847
+ let preset = null;
2848
+ if (schedSpec.preset) {
2849
+ preset = schedSpec.preset;
2850
+ try {
2851
+ cronExpr = resolvePreset(schedSpec.preset);
2852
+ } catch (err) {
2853
+ const msg = err instanceof Error ? err.message : String(err);
2854
+ throw validationError(msg);
2855
+ }
2856
+ } else if (schedSpec.cron) {
2857
+ cronExpr = schedSpec.cron;
2858
+ if (!validateCron(cronExpr)) throw validationError(`Invalid cron expression in schedule: ${cronExpr}`);
2859
+ } else {
2860
+ throw validationError('Schedule requires either "preset" or "cron"');
2861
+ }
2862
+ const timezone = schedSpec.timezone ?? "UTC";
2863
+ if (!isValidTimezone(timezone)) throw validationError(`Invalid timezone: ${timezone}`);
2864
+ resolvedSchedule = { cronExpr, preset, timezone };
2865
+ } else {
2866
+ deleteSchedule = true;
2867
+ }
2868
+ const rawSpec = request.body?.spec ?? {};
2869
+ const hasNotifications = "notifications" in rawSpec;
2870
+ if (hasNotifications) {
2871
+ for (const notif of config.spec.notifications) {
2872
+ const urlCheck = await resolveWebhookTarget(notif.url ?? "");
2873
+ if (!urlCheck.ok) throw validationError(`Notification URL invalid: ${urlCheck.message}`);
2874
+ }
2875
+ }
2896
2876
  const now = (/* @__PURE__ */ new Date()).toISOString();
2897
2877
  const name = config.metadata.name;
2898
- const existing = app.db.select().from(projects).where(eq8(projects.name, name)).get();
2899
2878
  let projectId;
2900
- if (existing) {
2901
- projectId = existing.id;
2902
- app.db.update(projects).set({
2903
- displayName: config.spec.displayName,
2904
- canonicalDomain: config.spec.canonicalDomain,
2905
- ownedDomains: JSON.stringify(config.spec.ownedDomains ?? []),
2906
- country: config.spec.country,
2907
- language: config.spec.language,
2908
- labels: JSON.stringify(config.metadata.labels),
2909
- providers: JSON.stringify(config.spec.providers ?? []),
2910
- locations: JSON.stringify(config.spec.locations ?? []),
2911
- defaultLocation: config.spec.defaultLocation ?? null,
2912
- configSource: "config-file",
2913
- configRevision: existing.configRevision + 1,
2914
- updatedAt: now
2915
- }).where(eq8(projects.id, existing.id)).run();
2916
- writeAuditLog(app.db, {
2917
- projectId,
2918
- actor: "api",
2919
- action: "project.applied",
2920
- entityType: "project",
2921
- entityId: projectId
2922
- });
2923
- } else {
2924
- projectId = crypto10.randomUUID();
2925
- app.db.insert(projects).values({
2926
- id: projectId,
2927
- name,
2928
- displayName: config.spec.displayName,
2929
- canonicalDomain: config.spec.canonicalDomain,
2930
- ownedDomains: JSON.stringify(config.spec.ownedDomains ?? []),
2931
- country: config.spec.country,
2932
- language: config.spec.language,
2933
- tags: "[]",
2934
- labels: JSON.stringify(config.metadata.labels),
2935
- providers: JSON.stringify(config.spec.providers ?? []),
2936
- locations: JSON.stringify(config.spec.locations ?? []),
2937
- defaultLocation: config.spec.defaultLocation ?? null,
2938
- configSource: "config-file",
2939
- configRevision: 1,
2940
- createdAt: now,
2941
- updatedAt: now
2942
- }).run();
2943
- writeAuditLog(app.db, {
2944
- projectId,
2945
- actor: "api",
2946
- action: "project.created",
2947
- entityType: "project",
2948
- entityId: projectId
2949
- });
2950
- }
2879
+ let scheduleAction = null;
2951
2880
  app.db.transaction((tx) => {
2881
+ const existing = tx.select().from(projects).where(eq8(projects.name, name)).get();
2882
+ if (existing) {
2883
+ projectId = existing.id;
2884
+ tx.update(projects).set({
2885
+ displayName: config.spec.displayName,
2886
+ canonicalDomain: config.spec.canonicalDomain,
2887
+ ownedDomains: JSON.stringify(config.spec.ownedDomains ?? []),
2888
+ country: config.spec.country,
2889
+ language: config.spec.language,
2890
+ labels: JSON.stringify(config.metadata.labels),
2891
+ providers: JSON.stringify(config.spec.providers ?? []),
2892
+ locations: JSON.stringify(config.spec.locations ?? []),
2893
+ defaultLocation: config.spec.defaultLocation ?? null,
2894
+ configSource: "config-file",
2895
+ configRevision: existing.configRevision + 1,
2896
+ updatedAt: now
2897
+ }).where(eq8(projects.id, existing.id)).run();
2898
+ writeAuditLog(tx, {
2899
+ projectId,
2900
+ actor: "api",
2901
+ action: "project.applied",
2902
+ entityType: "project",
2903
+ entityId: projectId
2904
+ });
2905
+ } else {
2906
+ projectId = crypto10.randomUUID();
2907
+ tx.insert(projects).values({
2908
+ id: projectId,
2909
+ name,
2910
+ displayName: config.spec.displayName,
2911
+ canonicalDomain: config.spec.canonicalDomain,
2912
+ ownedDomains: JSON.stringify(config.spec.ownedDomains ?? []),
2913
+ country: config.spec.country,
2914
+ language: config.spec.language,
2915
+ tags: "[]",
2916
+ labels: JSON.stringify(config.metadata.labels),
2917
+ providers: JSON.stringify(config.spec.providers ?? []),
2918
+ locations: JSON.stringify(config.spec.locations ?? []),
2919
+ defaultLocation: config.spec.defaultLocation ?? null,
2920
+ configSource: "config-file",
2921
+ configRevision: 1,
2922
+ createdAt: now,
2923
+ updatedAt: now
2924
+ }).run();
2925
+ writeAuditLog(tx, {
2926
+ projectId,
2927
+ actor: "api",
2928
+ action: "project.created",
2929
+ entityType: "project",
2930
+ entityId: projectId
2931
+ });
2932
+ }
2952
2933
  tx.delete(keywords).where(eq8(keywords.projectId, projectId)).run();
2953
2934
  for (const kw of config.spec.keywords) {
2954
2935
  tx.insert(keywords).values({
@@ -2981,98 +2962,63 @@ async function applyRoutes(app, opts) {
2981
2962
  entityType: "competitor",
2982
2963
  diff: { competitors: config.spec.competitors }
2983
2964
  });
2984
- });
2985
- if (config.spec.schedule) {
2986
- const schedSpec = config.spec.schedule;
2987
- let cronExpr;
2988
- let preset = null;
2989
- if (schedSpec.preset) {
2990
- preset = schedSpec.preset;
2991
- try {
2992
- cronExpr = resolvePreset(schedSpec.preset);
2993
- } catch (err) {
2994
- const msg = err instanceof Error ? err.message : String(err);
2995
- return reply.status(400).send({ error: { code: "VALIDATION_ERROR", message: msg } });
2965
+ if (resolvedSchedule) {
2966
+ const existingSched = tx.select().from(schedules).where(eq8(schedules.projectId, projectId)).get();
2967
+ if (existingSched) {
2968
+ tx.update(schedules).set({
2969
+ cronExpr: resolvedSchedule.cronExpr,
2970
+ preset: resolvedSchedule.preset,
2971
+ timezone: resolvedSchedule.timezone,
2972
+ providers: JSON.stringify(config.spec.schedule?.providers ?? []),
2973
+ enabled: 1,
2974
+ updatedAt: now
2975
+ }).where(eq8(schedules.id, existingSched.id)).run();
2976
+ } else {
2977
+ tx.insert(schedules).values({
2978
+ id: crypto10.randomUUID(),
2979
+ projectId,
2980
+ cronExpr: resolvedSchedule.cronExpr,
2981
+ preset: resolvedSchedule.preset,
2982
+ timezone: resolvedSchedule.timezone,
2983
+ enabled: 1,
2984
+ providers: JSON.stringify(config.spec.schedule?.providers ?? []),
2985
+ createdAt: now,
2986
+ updatedAt: now
2987
+ }).run();
2996
2988
  }
2997
- } else if (schedSpec.cron) {
2998
- cronExpr = schedSpec.cron;
2999
- if (!validateCron(cronExpr)) {
3000
- return reply.status(400).send({
3001
- error: { code: "VALIDATION_ERROR", message: `Invalid cron expression in schedule: ${cronExpr}` }
3002
- });
2989
+ scheduleAction = "upsert";
2990
+ } else if (deleteSchedule) {
2991
+ const existingSched = tx.select().from(schedules).where(eq8(schedules.projectId, projectId)).get();
2992
+ if (existingSched) {
2993
+ tx.delete(schedules).where(eq8(schedules.projectId, projectId)).run();
2994
+ scheduleAction = "delete";
3003
2995
  }
3004
- } else {
3005
- return reply.status(400).send({
3006
- error: { code: "VALIDATION_ERROR", message: 'Schedule requires either "preset" or "cron"' }
3007
- });
3008
2996
  }
3009
- const timezone = schedSpec.timezone ?? "UTC";
3010
- if (!isValidTimezone(timezone)) {
3011
- return reply.status(400).send({
3012
- error: { code: "VALIDATION_ERROR", message: `Invalid timezone: ${timezone}` }
3013
- });
3014
- }
3015
- const existingSched = app.db.select().from(schedules).where(eq8(schedules.projectId, projectId)).get();
3016
- if (existingSched) {
3017
- app.db.update(schedules).set({
3018
- cronExpr,
3019
- preset,
3020
- timezone,
3021
- providers: JSON.stringify(schedSpec.providers ?? []),
3022
- enabled: 1,
3023
- updatedAt: now
3024
- }).where(eq8(schedules.id, existingSched.id)).run();
3025
- } else {
3026
- app.db.insert(schedules).values({
3027
- id: crypto10.randomUUID(),
3028
- projectId,
3029
- cronExpr,
3030
- preset,
3031
- timezone,
3032
- enabled: 1,
3033
- providers: JSON.stringify(schedSpec.providers ?? []),
3034
- createdAt: now,
3035
- updatedAt: now
3036
- }).run();
3037
- }
3038
- opts?.onScheduleUpdated?.("upsert", projectId);
3039
- } else {
3040
- const existingSched = app.db.select().from(schedules).where(eq8(schedules.projectId, projectId)).get();
3041
- if (existingSched) {
3042
- app.db.delete(schedules).where(eq8(schedules.projectId, projectId)).run();
3043
- opts?.onScheduleUpdated?.("delete", projectId);
3044
- }
3045
- }
3046
- const rawSpec = request.body?.spec ?? {};
3047
- if ("notifications" in rawSpec) {
3048
- for (const notif of config.spec.notifications) {
3049
- const urlCheck = await resolveWebhookTarget(notif.url ?? "");
3050
- if (!urlCheck.ok) {
3051
- return reply.status(400).send({
3052
- error: { code: "VALIDATION_ERROR", message: `Notification URL invalid: ${urlCheck.message}` }
3053
- });
2997
+ if (hasNotifications) {
2998
+ tx.delete(notifications).where(eq8(notifications.projectId, projectId)).run();
2999
+ for (const notif of config.spec.notifications) {
3000
+ tx.insert(notifications).values({
3001
+ id: crypto10.randomUUID(),
3002
+ projectId,
3003
+ channel: notif.channel,
3004
+ config: JSON.stringify({ url: notif.url, events: notif.events }),
3005
+ webhookSecret: crypto10.randomBytes(32).toString("hex"),
3006
+ enabled: 1,
3007
+ createdAt: now,
3008
+ updatedAt: now
3009
+ }).run();
3054
3010
  }
3055
- }
3056
- app.db.delete(notifications).where(eq8(notifications.projectId, projectId)).run();
3057
- for (const notif of config.spec.notifications) {
3058
- app.db.insert(notifications).values({
3059
- id: crypto10.randomUUID(),
3011
+ writeAuditLog(tx, {
3060
3012
  projectId,
3061
- channel: notif.channel,
3062
- config: JSON.stringify({ url: notif.url, events: notif.events }),
3063
- webhookSecret: crypto10.randomBytes(32).toString("hex"),
3064
- enabled: 1,
3065
- createdAt: now,
3066
- updatedAt: now
3067
- }).run();
3013
+ actor: "api",
3014
+ action: "notifications.replaced",
3015
+ entityType: "notification",
3016
+ diff: { notifications: config.spec.notifications }
3017
+ });
3068
3018
  }
3069
- writeAuditLog(app.db, {
3070
- projectId,
3071
- actor: "api",
3072
- action: "notifications.replaced",
3073
- entityType: "notification",
3074
- diff: { notifications: config.spec.notifications }
3075
- });
3019
+ });
3020
+ if (scheduleAction) {
3021
+ opts?.onScheduleUpdated?.(scheduleAction, projectId);
3076
3022
  }
3077
3023
  if ("google" in rawSpec && config.spec.google?.gsc?.propertyUrl) {
3078
3024
  opts?.onGoogleConnectionPropertyUpdated?.(config.spec.canonicalDomain, "gsc", config.spec.google.gsc.propertyUrl);
@@ -3083,13 +3029,13 @@ async function applyRoutes(app, opts) {
3083
3029
  name: project.name,
3084
3030
  displayName: project.displayName,
3085
3031
  canonicalDomain: project.canonicalDomain,
3086
- ownedDomains: JSON.parse(project.ownedDomains || "[]"),
3032
+ ownedDomains: parseJsonColumn(project.ownedDomains, []),
3087
3033
  country: project.country,
3088
3034
  language: project.language,
3089
- tags: JSON.parse(project.tags),
3090
- labels: JSON.parse(project.labels),
3091
- providers: JSON.parse(project.providers || "[]"),
3092
- locations: JSON.parse(project.locations || "[]"),
3035
+ tags: parseJsonColumn(project.tags, []),
3036
+ labels: parseJsonColumn(project.labels, {}),
3037
+ providers: parseJsonColumn(project.providers, []),
3038
+ locations: parseJsonColumn(project.locations, []),
3093
3039
  defaultLocation: project.defaultLocation,
3094
3040
  configSource: project.configSource,
3095
3041
  configRevision: project.configRevision,
@@ -3145,8 +3091,7 @@ function redactNotificationDiff(value) {
3145
3091
  // ../api-routes/src/history.ts
3146
3092
  async function historyRoutes(app) {
3147
3093
  app.get("/projects/:name/history", async (request, reply) => {
3148
- const project = resolveProjectSafe4(app, request.params.name, reply);
3149
- if (!project) return;
3094
+ const project = resolveProject(app.db, request.params.name);
3150
3095
  const rows = app.db.select().from(auditLog).where(eq9(auditLog.projectId, project.id)).orderBy(desc2(auditLog.createdAt)).all();
3151
3096
  return reply.send(rows.map(formatAuditEntry));
3152
3097
  });
@@ -3155,8 +3100,7 @@ async function historyRoutes(app) {
3155
3100
  return reply.send(rows.map(formatAuditEntry));
3156
3101
  });
3157
3102
  app.get("/projects/:name/snapshots", async (request, reply) => {
3158
- const project = resolveProjectSafe4(app, request.params.name, reply);
3159
- if (!project) return;
3103
+ const project = resolveProject(app.db, request.params.name);
3160
3104
  const limit = parseInt(request.query.limit ?? "50", 10);
3161
3105
  const offset = parseInt(request.query.offset ?? "0", 10);
3162
3106
  const projectRuns = app.db.select({ id: runs.id }).from(runs).where(eq9(runs.projectId, project.id)).all();
@@ -3174,6 +3118,7 @@ async function historyRoutes(app) {
3174
3118
  answerText: querySnapshots.answerText,
3175
3119
  citedDomains: querySnapshots.citedDomains,
3176
3120
  competitorOverlap: querySnapshots.competitorOverlap,
3121
+ recommendedCompetitors: querySnapshots.recommendedCompetitors,
3177
3122
  location: querySnapshots.location,
3178
3123
  createdAt: querySnapshots.createdAt
3179
3124
  }).from(querySnapshots).leftJoin(keywords, eq9(querySnapshots.keywordId, keywords.id)).where(inArray(querySnapshots.runId, projectRuns.map((r) => r.id))).orderBy(desc2(querySnapshots.createdAt)).all();
@@ -3191,8 +3136,9 @@ async function historyRoutes(app) {
3191
3136
  model: s.model,
3192
3137
  citationState: s.citationState,
3193
3138
  answerText: s.answerText,
3194
- citedDomains: tryParseJson2(s.citedDomains, []),
3195
- competitorOverlap: tryParseJson2(s.competitorOverlap, []),
3139
+ citedDomains: parseJsonColumn(s.citedDomains, []),
3140
+ competitorOverlap: parseJsonColumn(s.competitorOverlap, []),
3141
+ recommendedCompetitors: parseJsonColumn(s.recommendedCompetitors, []),
3196
3142
  location: s.location,
3197
3143
  createdAt: s.createdAt
3198
3144
  })),
@@ -3200,8 +3146,7 @@ async function historyRoutes(app) {
3200
3146
  });
3201
3147
  });
3202
3148
  app.get("/projects/:name/timeline", async (request, reply) => {
3203
- const project = resolveProjectSafe4(app, request.params.name, reply);
3204
- if (!project) return;
3149
+ const project = resolveProject(app.db, request.params.name);
3205
3150
  const projectKeywords = app.db.select().from(keywords).where(eq9(keywords.projectId, project.id)).all();
3206
3151
  const projectRuns = app.db.select().from(runs).where(eq9(runs.projectId, project.id)).orderBy(runs.createdAt).all();
3207
3152
  if (projectRuns.length === 0 || projectKeywords.length === 0) {
@@ -3283,11 +3228,10 @@ async function historyRoutes(app) {
3283
3228
  return reply.send(timeline);
3284
3229
  });
3285
3230
  app.get("/projects/:name/snapshots/diff", async (request, reply) => {
3286
- const project = resolveProjectSafe4(app, request.params.name, reply);
3287
- if (!project) return;
3231
+ resolveProject(app.db, request.params.name);
3288
3232
  const { run1, run2 } = request.query;
3289
3233
  if (!run1 || !run2) {
3290
- return reply.status(400).send({ error: { code: "VALIDATION_ERROR", message: "Both run1 and run2 query params are required" } });
3234
+ throw validationError("Both run1 and run2 query params are required");
3291
3235
  }
3292
3236
  const snaps1 = app.db.select({
3293
3237
  keywordId: querySnapshots.keywordId,
@@ -3332,36 +3276,16 @@ function formatAuditEntry(row) {
3332
3276
  action: row.action,
3333
3277
  entityType: row.entityType,
3334
3278
  entityId: row.entityId,
3335
- diff: row.diff ? row.entityType === "notification" ? redactNotificationDiff(tryParseJson2(row.diff, null)) : tryParseJson2(row.diff, null) : null,
3279
+ diff: row.diff ? row.entityType === "notification" ? redactNotificationDiff(parseJsonColumn(row.diff, null)) : parseJsonColumn(row.diff, null) : null,
3336
3280
  createdAt: row.createdAt
3337
3281
  };
3338
3282
  }
3339
- function tryParseJson2(value, fallback) {
3340
- try {
3341
- return JSON.parse(value);
3342
- } catch {
3343
- return fallback;
3344
- }
3345
- }
3346
- function resolveProjectSafe4(app, name, reply) {
3347
- try {
3348
- return resolveProject(app.db, name);
3349
- } catch (e) {
3350
- if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
3351
- const err = e;
3352
- reply.status(err.statusCode).send(err.toJSON());
3353
- return null;
3354
- }
3355
- throw e;
3356
- }
3357
- }
3358
3283
 
3359
3284
  // ../api-routes/src/analytics.ts
3360
3285
  import { eq as eq10, desc as desc3, inArray as inArray2 } from "drizzle-orm";
3361
3286
  async function analyticsRoutes(app) {
3362
3287
  app.get("/projects/:name/analytics/metrics", async (request, reply) => {
3363
- const project = resolveProjectSafe5(app, request.params.name, reply);
3364
- if (!project) return;
3288
+ const project = resolveProject(app.db, request.params.name);
3365
3289
  const window = parseWindow(request.query.window);
3366
3290
  const cutoff = windowCutoff(window);
3367
3291
  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);
@@ -3401,8 +3325,7 @@ async function analyticsRoutes(app) {
3401
3325
  return reply.send({ window, buckets, overall, byProvider, trend, keywordChanges });
3402
3326
  });
3403
3327
  app.get("/projects/:name/analytics/gaps", async (request, reply) => {
3404
- const project = resolveProjectSafe5(app, request.params.name, reply);
3405
- if (!project) return;
3328
+ const project = resolveProject(app.db, request.params.name);
3406
3329
  const window = parseWindow(request.query.window);
3407
3330
  const cutoff = windowCutoff(window);
3408
3331
  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");
@@ -3450,7 +3373,7 @@ async function analyticsRoutes(app) {
3450
3373
  const citedProviders = kwSnapshots.filter((s) => s.citationState === "cited").map((s) => s.provider);
3451
3374
  const competitorsCiting = /* @__PURE__ */ new Set();
3452
3375
  for (const s of kwSnapshots) {
3453
- const overlap = tryParseJson3(s.competitorOverlap, []);
3376
+ const overlap = parseJsonColumn(s.competitorOverlap, []);
3454
3377
  for (const c of overlap) competitorsCiting.add(c);
3455
3378
  }
3456
3379
  let category;
@@ -3483,8 +3406,7 @@ async function analyticsRoutes(app) {
3483
3406
  return reply.send({ cited, gap, uncited, runId: latestRun.id, window });
3484
3407
  });
3485
3408
  app.get("/projects/:name/analytics/sources", async (request, reply) => {
3486
- const project = resolveProjectSafe5(app, request.params.name, reply);
3487
- if (!project) return;
3409
+ const project = resolveProject(app.db, request.params.name);
3488
3410
  const window = parseWindow(request.query.window);
3489
3411
  const cutoff = windowCutoff(window);
3490
3412
  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);
@@ -3520,26 +3442,6 @@ async function analyticsRoutes(app) {
3520
3442
  return reply.send({ overall, byKeyword, runId: latestRunId, window });
3521
3443
  });
3522
3444
  }
3523
- function resolveProjectSafe5(app, name, reply) {
3524
- try {
3525
- return resolveProject(app.db, name);
3526
- } catch (e) {
3527
- if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
3528
- const err = e;
3529
- reply.status(err.statusCode).send(err.toJSON());
3530
- return null;
3531
- }
3532
- throw e;
3533
- }
3534
- }
3535
- function tryParseJson3(value, fallback) {
3536
- if (!value) return fallback;
3537
- try {
3538
- return JSON.parse(value);
3539
- } catch {
3540
- return fallback;
3541
- }
3542
- }
3543
3445
  var PROVIDER_INFRA_DOMAINS = /* @__PURE__ */ new Set([
3544
3446
  "vertexaisearch.cloud.google.com",
3545
3447
  "openai.com",
@@ -3557,7 +3459,7 @@ function isProviderInfraDomain(uri) {
3557
3459
  return false;
3558
3460
  }
3559
3461
  function parseGroundingSources(rawResponse) {
3560
- const parsed = tryParseJson3(rawResponse, {});
3462
+ const parsed = parseJsonColumn(rawResponse, {});
3561
3463
  const sources = parsed.groundingSources;
3562
3464
  if (!Array.isArray(sources)) return [];
3563
3465
  return sources.filter(
@@ -5338,6 +5240,45 @@ var routeCatalog = [
5338
5240
  404: { description: "Project, connection, or page not found." }
5339
5241
  }
5340
5242
  },
5243
+ {
5244
+ method: "post",
5245
+ path: "/api/v1/projects/{name}/wordpress/pages/meta/bulk",
5246
+ summary: "Bulk update SEO meta for multiple pages",
5247
+ tags: ["wordpress"],
5248
+ parameters: [nameParameter],
5249
+ requestBody: {
5250
+ required: true,
5251
+ content: {
5252
+ "application/json": {
5253
+ schema: {
5254
+ type: "object",
5255
+ required: ["entries"],
5256
+ properties: {
5257
+ entries: {
5258
+ type: "array",
5259
+ items: {
5260
+ type: "object",
5261
+ required: ["slug"],
5262
+ properties: {
5263
+ slug: stringSchema,
5264
+ title: stringSchema,
5265
+ description: stringSchema,
5266
+ noindex: booleanSchema
5267
+ }
5268
+ }
5269
+ },
5270
+ env: { type: "string", enum: ["live", "staging"] }
5271
+ }
5272
+ }
5273
+ }
5274
+ }
5275
+ },
5276
+ responses: {
5277
+ 200: { description: "Bulk SEO meta update results returned." },
5278
+ 400: { description: "Invalid entries or environment." },
5279
+ 404: { description: "Project or connection not found." }
5280
+ }
5281
+ },
5341
5282
  {
5342
5283
  method: "get",
5343
5284
  path: "/api/v1/projects/{name}/wordpress/schema",
@@ -5379,6 +5320,48 @@ var routeCatalog = [
5379
5320
  404: { description: "Project, connection, or page not found." }
5380
5321
  }
5381
5322
  },
5323
+ {
5324
+ method: "post",
5325
+ path: "/api/v1/projects/{name}/wordpress/schema/deploy",
5326
+ summary: "Deploy JSON-LD schema to WordPress pages",
5327
+ tags: ["wordpress"],
5328
+ parameters: [nameParameter],
5329
+ requestBody: {
5330
+ required: true,
5331
+ content: {
5332
+ "application/json": {
5333
+ schema: {
5334
+ type: "object",
5335
+ required: ["profile"],
5336
+ properties: {
5337
+ profile: {
5338
+ type: "object",
5339
+ description: "Business profile and per-slug schema mapping"
5340
+ },
5341
+ env: { type: "string", enum: ["live", "staging"] }
5342
+ }
5343
+ }
5344
+ }
5345
+ }
5346
+ },
5347
+ responses: {
5348
+ 200: { description: "Schema deployment results returned." },
5349
+ 400: { description: "Invalid profile or environment." },
5350
+ 404: { description: "Project or connection not found." }
5351
+ }
5352
+ },
5353
+ {
5354
+ method: "get",
5355
+ path: "/api/v1/projects/{name}/wordpress/schema/status",
5356
+ summary: "Get JSON-LD schema status for all pages",
5357
+ tags: ["wordpress"],
5358
+ parameters: [nameParameter, wordpressEnvQueryParameter],
5359
+ responses: {
5360
+ 200: { description: "Schema status per page returned." },
5361
+ 400: { description: "Invalid environment." },
5362
+ 404: { description: "Project or connection not found." }
5363
+ }
5364
+ },
5382
5365
  {
5383
5366
  method: "get",
5384
5367
  path: "/api/v1/projects/{name}/wordpress/llms-txt",
@@ -5466,6 +5449,39 @@ var routeCatalog = [
5466
5449
  404: { description: "Project or connection not found." }
5467
5450
  }
5468
5451
  },
5452
+ {
5453
+ method: "post",
5454
+ path: "/api/v1/projects/{name}/wordpress/onboard",
5455
+ summary: "Full WordPress onboarding workflow",
5456
+ tags: ["wordpress"],
5457
+ parameters: [nameParameter],
5458
+ requestBody: {
5459
+ required: true,
5460
+ content: {
5461
+ "application/json": {
5462
+ schema: {
5463
+ type: "object",
5464
+ required: ["url", "username", "appPassword"],
5465
+ properties: {
5466
+ url: stringSchema,
5467
+ stagingUrl: stringSchema,
5468
+ username: stringSchema,
5469
+ appPassword: stringSchema,
5470
+ defaultEnv: { type: "string", enum: ["live", "staging"] },
5471
+ profile: objectSchema,
5472
+ skipSchema: booleanSchema,
5473
+ skipSubmit: booleanSchema
5474
+ }
5475
+ }
5476
+ }
5477
+ }
5478
+ },
5479
+ responses: {
5480
+ 200: { description: "Onboarding result with step-by-step status." },
5481
+ 400: { description: "Invalid onboarding request." },
5482
+ 404: { description: "Project not found." }
5483
+ }
5484
+ },
5469
5485
  // GA4 routes
5470
5486
  {
5471
5487
  method: "post",
@@ -5519,7 +5535,7 @@ var routeCatalog = [
5519
5535
  {
5520
5536
  method: "post",
5521
5537
  path: "/api/v1/projects/{name}/ga/sync",
5522
- summary: "Sync GA4 traffic data",
5538
+ summary: "Sync GA4 traffic and AI referral data",
5523
5539
  tags: ["ga4"],
5524
5540
  parameters: [nameParameter],
5525
5541
  requestBody: {
@@ -5544,7 +5560,7 @@ var routeCatalog = [
5544
5560
  {
5545
5561
  method: "get",
5546
5562
  path: "/api/v1/projects/{name}/ga/traffic",
5547
- summary: "Get GA4 landing page traffic",
5563
+ summary: "Get GA4 landing page traffic and AI referral sources",
5548
5564
  tags: ["ga4"],
5549
5565
  parameters: [nameParameter, limitQueryParameter],
5550
5566
  responses: {
@@ -5813,34 +5829,29 @@ import crypto11 from "crypto";
5813
5829
  import { eq as eq11 } from "drizzle-orm";
5814
5830
  async function scheduleRoutes(app, opts) {
5815
5831
  app.put("/projects/:name/schedule", async (request, reply) => {
5816
- const project = resolveProjectSafe6(app, request.params.name, reply);
5817
- if (!project) return;
5832
+ const project = resolveProject(app.db, request.params.name);
5818
5833
  const parsedBody = scheduleUpsertRequestSchema.safeParse(request.body);
5819
5834
  if (!parsedBody.success) {
5820
- const err = validationError("Invalid schedule payload", {
5835
+ throw validationError("Invalid schedule payload", {
5821
5836
  issues: parsedBody.error.issues.map((issue) => ({
5822
5837
  path: issue.path.join("."),
5823
5838
  message: issue.message
5824
5839
  }))
5825
5840
  });
5826
- return reply.status(err.statusCode).send(err.toJSON());
5827
5841
  }
5828
5842
  const { preset, cron: cron2, timezone, providers, enabled } = parsedBody.data;
5829
5843
  const validNames = opts.validProviderNames ?? [];
5830
5844
  if (validNames.length && providers?.length) {
5831
5845
  const invalid = providers.filter((p) => !validNames.includes(p));
5832
5846
  if (invalid.length) {
5833
- const err = validationError(`Invalid provider(s): ${invalid.join(", ")}. Must be one of: ${validNames.join(", ")}`, {
5847
+ throw validationError(`Invalid provider(s): ${invalid.join(", ")}. Must be one of: ${validNames.join(", ")}`, {
5834
5848
  invalidProviders: invalid,
5835
5849
  validProviders: validNames
5836
5850
  });
5837
- return reply.status(err.statusCode).send(err.toJSON());
5838
5851
  }
5839
5852
  }
5840
5853
  if (!isValidTimezone(timezone)) {
5841
- return reply.status(400).send({
5842
- error: { code: "VALIDATION_ERROR", message: `Invalid timezone: ${timezone}` }
5843
- });
5854
+ throw validationError(`Invalid timezone: ${timezone}`);
5844
5855
  }
5845
5856
  let cronExpr;
5846
5857
  if (preset) {
@@ -5848,14 +5859,12 @@ async function scheduleRoutes(app, opts) {
5848
5859
  cronExpr = resolvePreset(preset);
5849
5860
  } catch (err) {
5850
5861
  const msg = err instanceof Error ? err.message : String(err);
5851
- return reply.status(400).send({ error: { code: "VALIDATION_ERROR", message: msg } });
5862
+ throw validationError(msg);
5852
5863
  }
5853
5864
  } else {
5854
5865
  cronExpr = cron2;
5855
5866
  if (!validateCron(cronExpr)) {
5856
- return reply.status(400).send({
5857
- error: { code: "VALIDATION_ERROR", message: `Invalid cron expression: ${cronExpr}` }
5858
- });
5867
+ throw validationError(`Invalid cron expression: ${cronExpr}`);
5859
5868
  }
5860
5869
  }
5861
5870
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -5895,20 +5904,18 @@ async function scheduleRoutes(app, opts) {
5895
5904
  return reply.status(existing ? 200 : 201).send(formatSchedule(schedule));
5896
5905
  });
5897
5906
  app.get("/projects/:name/schedule", async (request, reply) => {
5898
- const project = resolveProjectSafe6(app, request.params.name, reply);
5899
- if (!project) return;
5907
+ const project = resolveProject(app.db, request.params.name);
5900
5908
  const schedule = app.db.select().from(schedules).where(eq11(schedules.projectId, project.id)).get();
5901
5909
  if (!schedule) {
5902
- return reply.status(404).send({ error: { code: "NOT_FOUND", message: `No schedule for project '${request.params.name}'` } });
5910
+ throw notFound("Schedule", request.params.name);
5903
5911
  }
5904
5912
  return reply.send(formatSchedule(schedule));
5905
5913
  });
5906
5914
  app.delete("/projects/:name/schedule", async (request, reply) => {
5907
- const project = resolveProjectSafe6(app, request.params.name, reply);
5908
- if (!project) return;
5915
+ const project = resolveProject(app.db, request.params.name);
5909
5916
  const schedule = app.db.select().from(schedules).where(eq11(schedules.projectId, project.id)).get();
5910
5917
  if (!schedule) {
5911
- return reply.status(404).send({ error: { code: "NOT_FOUND", message: `No schedule for project '${request.params.name}'` } });
5918
+ throw notFound("Schedule", request.params.name);
5912
5919
  }
5913
5920
  app.db.delete(schedules).where(eq11(schedules.id, schedule.id)).run();
5914
5921
  writeAuditLog(app.db, {
@@ -5930,25 +5937,13 @@ function formatSchedule(row) {
5930
5937
  preset: row.preset,
5931
5938
  timezone: row.timezone,
5932
5939
  enabled: row.enabled === 1,
5933
- providers: JSON.parse(row.providers),
5940
+ providers: parseJsonColumn(row.providers, []),
5934
5941
  lastRunAt: row.lastRunAt,
5935
5942
  nextRunAt: row.nextRunAt,
5936
5943
  createdAt: row.createdAt,
5937
5944
  updatedAt: row.updatedAt
5938
5945
  };
5939
5946
  }
5940
- function resolveProjectSafe6(app, name, reply) {
5941
- try {
5942
- return resolveProject(app.db, name);
5943
- } catch (e) {
5944
- if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
5945
- const err = e;
5946
- reply.status(err.statusCode).send(err.toJSON());
5947
- return null;
5948
- }
5949
- throw e;
5950
- }
5951
- }
5952
5947
 
5953
5948
  // ../api-routes/src/notifications.ts
5954
5949
  import crypto12 from "crypto";
@@ -5959,30 +5954,15 @@ async function notificationRoutes(app) {
5959
5954
  return reply.send(VALID_EVENTS);
5960
5955
  });
5961
5956
  app.post("/projects/:name/notifications", async (request, reply) => {
5962
- const project = resolveProjectSafe7(app, request.params.name, reply);
5963
- if (!project) return;
5957
+ const project = resolveProject(app.db, request.params.name);
5964
5958
  const { channel, url, events } = request.body ?? {};
5965
- if (channel !== "webhook") {
5966
- return reply.status(400).send({
5967
- error: { code: "VALIDATION_ERROR", message: 'Only "webhook" channel is supported' }
5968
- });
5969
- }
5959
+ if (channel !== "webhook") throw validationError('Only "webhook" channel is supported');
5970
5960
  const urlCheck = await resolveWebhookTarget(url ?? "");
5971
- if (!urlCheck.ok) {
5972
- return reply.status(400).send({
5973
- error: { code: "VALIDATION_ERROR", message: urlCheck.message }
5974
- });
5975
- }
5976
- if (!events?.length) {
5977
- return reply.status(400).send({
5978
- error: { code: "VALIDATION_ERROR", message: '"events" must be a non-empty array' }
5979
- });
5980
- }
5961
+ if (!urlCheck.ok) throw validationError(urlCheck.message);
5962
+ if (!events?.length) throw validationError('"events" must be a non-empty array');
5981
5963
  const invalid = events.filter((e) => !VALID_EVENTS.includes(e));
5982
5964
  if (invalid.length) {
5983
- return reply.status(400).send({
5984
- error: { code: "VALIDATION_ERROR", message: `Invalid event(s): ${invalid.join(", ")}. Must be one of: ${VALID_EVENTS.join(", ")}` }
5985
- });
5965
+ throw validationError(`Invalid event(s): ${invalid.join(", ")}. Must be one of: ${VALID_EVENTS.join(", ")}`);
5986
5966
  }
5987
5967
  const now = (/* @__PURE__ */ new Date()).toISOString();
5988
5968
  const id = crypto12.randomUUID();
@@ -6011,19 +5991,15 @@ async function notificationRoutes(app) {
6011
5991
  });
6012
5992
  });
6013
5993
  app.get("/projects/:name/notifications", async (request, reply) => {
6014
- const project = resolveProjectSafe7(app, request.params.name, reply);
6015
- if (!project) return;
5994
+ const project = resolveProject(app.db, request.params.name);
6016
5995
  const rows = app.db.select().from(notifications).where(eq12(notifications.projectId, project.id)).all();
6017
5996
  return reply.send(rows.map(formatNotification));
6018
5997
  });
6019
5998
  app.delete("/projects/:name/notifications/:id", async (request, reply) => {
6020
- const project = resolveProjectSafe7(app, request.params.name, reply);
6021
- if (!project) return;
5999
+ const project = resolveProject(app.db, request.params.name);
6022
6000
  const notification = app.db.select().from(notifications).where(eq12(notifications.id, request.params.id)).get();
6023
6001
  if (!notification || notification.projectId !== project.id) {
6024
- return reply.status(404).send({
6025
- error: { code: "NOT_FOUND", message: `Notification '${request.params.id}' not found` }
6026
- });
6002
+ throw notFound("Notification", request.params.id);
6027
6003
  }
6028
6004
  app.db.delete(notifications).where(eq12(notifications.id, notification.id)).run();
6029
6005
  writeAuditLog(app.db, {
@@ -6036,21 +6012,14 @@ async function notificationRoutes(app) {
6036
6012
  return reply.status(204).send();
6037
6013
  });
6038
6014
  app.post("/projects/:name/notifications/:id/test", async (request, reply) => {
6039
- const project = resolveProjectSafe7(app, request.params.name, reply);
6040
- if (!project) return;
6015
+ const project = resolveProject(app.db, request.params.name);
6041
6016
  const notification = app.db.select().from(notifications).where(eq12(notifications.id, request.params.id)).get();
6042
6017
  if (!notification || notification.projectId !== project.id) {
6043
- return reply.status(404).send({
6044
- error: { code: "NOT_FOUND", message: `Notification '${request.params.id}' not found` }
6045
- });
6018
+ throw notFound("Notification", request.params.id);
6046
6019
  }
6047
- const config = JSON.parse(notification.config);
6020
+ const config = parseJsonColumn(notification.config, { url: "", events: [] });
6048
6021
  const urlCheck = await resolveWebhookTarget(config.url);
6049
- if (!urlCheck.ok) {
6050
- return reply.status(400).send({
6051
- error: { code: "VALIDATION_ERROR", message: `Stored webhook URL is invalid: ${urlCheck.message}` }
6052
- });
6053
- }
6022
+ if (!urlCheck.ok) throw validationError(`Stored webhook URL is invalid: ${urlCheck.message}`);
6054
6023
  const payload = {
6055
6024
  source: "canonry",
6056
6025
  event: "run.completed",
@@ -6073,14 +6042,12 @@ async function notificationRoutes(app) {
6073
6042
  entityId: notification.id,
6074
6043
  diff: { status, error }
6075
6044
  });
6076
- if (error) {
6077
- return reply.status(502).send({ error: { code: "DELIVERY_FAILED", message: error } });
6078
- }
6045
+ if (error) throw deliveryFailed(error);
6079
6046
  return reply.send({ status, ok: status >= 200 && status < 300 });
6080
6047
  });
6081
6048
  }
6082
6049
  function formatNotification(row) {
6083
- const config = JSON.parse(row.config);
6050
+ const config = parseJsonColumn(row.config, { url: "", events: [] });
6084
6051
  const redacted = redactNotificationUrl(config.url);
6085
6052
  return {
6086
6053
  id: row.id,
@@ -6095,22 +6062,10 @@ function formatNotification(row) {
6095
6062
  updatedAt: row.updatedAt
6096
6063
  };
6097
6064
  }
6098
- function resolveProjectSafe7(app, name, reply) {
6099
- try {
6100
- return resolveProject(app.db, name);
6101
- } catch (e) {
6102
- if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
6103
- const err = e;
6104
- reply.status(err.statusCode).send(err.toJSON());
6105
- return null;
6106
- }
6107
- throw e;
6108
- }
6109
- }
6110
6065
 
6111
6066
  // ../api-routes/src/google.ts
6112
6067
  import crypto13 from "crypto";
6113
- import { eq as eq13, and as and3, desc as desc4, sql as sql2 } from "drizzle-orm";
6068
+ import { eq as eq13, and as and2, desc as desc4, sql as sql3 } from "drizzle-orm";
6114
6069
 
6115
6070
  // ../integration-google/src/constants.ts
6116
6071
  var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
@@ -6557,11 +6512,11 @@ async function googleRoutes(app, opts) {
6557
6512
  const project = resolveProject(app.db, request.params.name);
6558
6513
  const { startDate, endDate, query, page, limit } = request.query;
6559
6514
  const conditions = [eq13(gscSearchData.projectId, project.id)];
6560
- if (startDate) conditions.push(sql2`${gscSearchData.date} >= ${startDate}`);
6561
- if (endDate) conditions.push(sql2`${gscSearchData.date} <= ${endDate}`);
6562
- if (query) conditions.push(sql2`${gscSearchData.query} LIKE ${"%" + query + "%"}`);
6563
- if (page) conditions.push(sql2`${gscSearchData.page} LIKE ${"%" + page + "%"}`);
6564
- const rows = app.db.select().from(gscSearchData).where(and3(...conditions)).orderBy(desc4(gscSearchData.date)).limit(parseInt(limit ?? "500", 10)).all();
6515
+ if (startDate) conditions.push(sql3`${gscSearchData.date} >= ${startDate}`);
6516
+ if (endDate) conditions.push(sql3`${gscSearchData.date} <= ${endDate}`);
6517
+ if (query) conditions.push(sql3`${gscSearchData.query} LIKE ${"%" + query + "%"}`);
6518
+ if (page) conditions.push(sql3`${gscSearchData.page} LIKE ${"%" + page + "%"}`);
6519
+ const rows = app.db.select().from(gscSearchData).where(and2(...conditions)).orderBy(desc4(gscSearchData.date)).limit(parseInt(limit ?? "500", 10)).all();
6565
6520
  return rows.map((r) => ({
6566
6521
  date: r.date,
6567
6522
  query: r.query,
@@ -6639,7 +6594,7 @@ async function googleRoutes(app, opts) {
6639
6594
  const { url, limit } = request.query;
6640
6595
  const conditions = [eq13(gscUrlInspections.projectId, project.id)];
6641
6596
  if (url) conditions.push(eq13(gscUrlInspections.url, url));
6642
- const rows = app.db.select().from(gscUrlInspections).where(and3(...conditions)).orderBy(desc4(gscUrlInspections.inspectedAt)).limit(parseInt(limit ?? "100", 10)).all();
6597
+ const rows = app.db.select().from(gscUrlInspections).where(and2(...conditions)).orderBy(desc4(gscUrlInspections.inspectedAt)).limit(parseInt(limit ?? "100", 10)).all();
6643
6598
  return rows.map((r) => ({
6644
6599
  id: r.id,
6645
6600
  url: r.url,
@@ -7011,7 +6966,7 @@ async function googleRoutes(app, opts) {
7011
6966
 
7012
6967
  // ../api-routes/src/bing.ts
7013
6968
  import crypto14 from "crypto";
7014
- import { eq as eq14, and as and4, desc as desc5 } from "drizzle-orm";
6969
+ import { eq as eq14, and as and3, desc as desc5 } from "drizzle-orm";
7015
6970
 
7016
6971
  // ../integration-bing/src/constants.ts
7017
6972
  var BING_WMT_API_BASE = "https://ssl.bing.com/webmaster/api.svc/json";
@@ -7310,7 +7265,7 @@ async function bingRoutes(app, opts) {
7310
7265
  if (!store) return;
7311
7266
  const project = resolveProject(app.db, request.params.name);
7312
7267
  const { url, limit } = request.query;
7313
- const whereClause = url ? and4(eq14(bingUrlInspections.projectId, project.id), eq14(bingUrlInspections.url, url)) : eq14(bingUrlInspections.projectId, project.id);
7268
+ const whereClause = url ? and3(eq14(bingUrlInspections.projectId, project.id), eq14(bingUrlInspections.url, url)) : eq14(bingUrlInspections.projectId, project.id);
7314
7269
  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();
7315
7270
  return filtered.map((r) => ({
7316
7271
  id: r.id,
@@ -7500,7 +7455,7 @@ async function bingRoutes(app, opts) {
7500
7455
  import fs2 from "fs";
7501
7456
  import path2 from "path";
7502
7457
  import os2 from "os";
7503
- import { eq as eq15, and as and5 } from "drizzle-orm";
7458
+ import { eq as eq15, and as and4 } from "drizzle-orm";
7504
7459
  function getScreenshotDir() {
7505
7460
  return path2.join(os2.homedir(), ".canonry", "screenshots");
7506
7461
  }
@@ -7573,7 +7528,7 @@ async function cdpRoutes(app, opts) {
7573
7528
  async (request, reply) => {
7574
7529
  const project = resolveProject(app.db, request.params.name);
7575
7530
  const { runId } = request.params;
7576
- const run = app.db.select().from(runs).where(and5(eq15(runs.id, runId), eq15(runs.projectId, project.id))).get();
7531
+ const run = app.db.select().from(runs).where(and4(eq15(runs.id, runId), eq15(runs.projectId, project.id))).get();
7577
7532
  if (!run) {
7578
7533
  const err = notFound("Run", runId);
7579
7534
  return reply.code(err.statusCode).send(err.toJSON());
@@ -7670,7 +7625,7 @@ async function cdpRoutes(app, opts) {
7670
7625
 
7671
7626
  // ../api-routes/src/ga.ts
7672
7627
  import crypto16 from "crypto";
7673
- import { eq as eq16, desc as desc6, and as and6, sql as sql3 } from "drizzle-orm";
7628
+ import { eq as eq16, desc as desc6, and as and5, sql as sql4 } from "drizzle-orm";
7674
7629
 
7675
7630
  // ../integration-google-analytics/src/ga4-client.ts
7676
7631
  import crypto15 from "crypto";
@@ -7809,6 +7764,15 @@ async function batchRunReports(accessToken, propertyId, requests) {
7809
7764
  function formatDate(d) {
7810
7765
  return d.toISOString().split("T")[0];
7811
7766
  }
7767
+ var AI_REFERRAL_SOURCE_FILTERS = [
7768
+ { matchType: "CONTAINS", value: "perplexity" },
7769
+ { matchType: "CONTAINS", value: "gemini" },
7770
+ { matchType: "CONTAINS", value: "chatgpt" },
7771
+ { matchType: "CONTAINS", value: "openai" },
7772
+ { matchType: "CONTAINS", value: "claude" },
7773
+ { matchType: "CONTAINS", value: "anthropic" },
7774
+ { matchType: "CONTAINS", value: "copilot" }
7775
+ ];
7812
7776
  async function fetchTrafficByLandingPage(accessToken, propertyId, days) {
7813
7777
  const syncDays = Math.min(Math.max(1, days ?? GA4_DEFAULT_SYNC_DAYS), GA4_MAX_SYNC_DAYS);
7814
7778
  const endDate = /* @__PURE__ */ new Date();
@@ -7935,6 +7899,61 @@ async function fetchAggregateSummary(accessToken, propertyId, days) {
7935
7899
  ga4Log("info", "fetch-aggregate.done", { propertyId, ...summary });
7936
7900
  return summary;
7937
7901
  }
7902
+ async function fetchAiReferrals(accessToken, propertyId, days) {
7903
+ const syncDays = Math.min(Math.max(1, days ?? GA4_DEFAULT_SYNC_DAYS), GA4_MAX_SYNC_DAYS);
7904
+ const endDate = /* @__PURE__ */ new Date();
7905
+ const startDate = /* @__PURE__ */ new Date();
7906
+ startDate.setDate(startDate.getDate() - syncDays);
7907
+ ga4Log("info", "fetch-ai-referrals.start", { propertyId, days: syncDays });
7908
+ const PAGE_SIZE = 1e3;
7909
+ const rows = [];
7910
+ let offset = 0;
7911
+ while (true) {
7912
+ const request = {
7913
+ dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
7914
+ dimensions: [
7915
+ { name: "date" },
7916
+ { name: "sessionSource" },
7917
+ { name: "sessionMedium" }
7918
+ ],
7919
+ metrics: [
7920
+ { name: "sessions" },
7921
+ { name: "totalUsers" }
7922
+ ],
7923
+ dimensionFilter: {
7924
+ orGroup: {
7925
+ expressions: AI_REFERRAL_SOURCE_FILTERS.map(({ matchType, value }) => ({
7926
+ filter: {
7927
+ fieldName: "sessionSource",
7928
+ stringFilter: { matchType, value }
7929
+ }
7930
+ }))
7931
+ }
7932
+ },
7933
+ limit: PAGE_SIZE,
7934
+ offset
7935
+ };
7936
+ const response = await runReport(accessToken, propertyId, request);
7937
+ const pageRows = (response.rows ?? []).map((row) => ({
7938
+ date: row.dimensionValues[0].value,
7939
+ source: row.dimensionValues[1].value,
7940
+ medium: row.dimensionValues[2].value,
7941
+ sessions: parseInt(row.metricValues[0].value, 10) || 0,
7942
+ users: parseInt(row.metricValues[1].value, 10) || 0
7943
+ }));
7944
+ rows.push(...pageRows);
7945
+ const totalRows = response.rowCount ?? 0;
7946
+ offset += pageRows.length;
7947
+ if (pageRows.length < PAGE_SIZE || offset >= totalRows) break;
7948
+ }
7949
+ for (const row of rows) {
7950
+ if (row.date.length === 8 && !row.date.includes("-")) {
7951
+ row.date = `${row.date.slice(0, 4)}-${row.date.slice(4, 6)}-${row.date.slice(6, 8)}`;
7952
+ }
7953
+ }
7954
+ ga4Log("info", "fetch-ai-referrals.done", { propertyId, rowCount: rows.length });
7955
+ return rows;
7956
+ }
7938
7957
 
7939
7958
  // ../api-routes/src/ga.ts
7940
7959
  function gaLog(level, action, ctx) {
@@ -8020,6 +8039,7 @@ async function ga4Routes(app, opts) {
8020
8039
  }
8021
8040
  app.db.delete(gaTrafficSnapshots).where(eq16(gaTrafficSnapshots.projectId, project.id)).run();
8022
8041
  app.db.delete(gaTrafficSummaries).where(eq16(gaTrafficSummaries.projectId, project.id)).run();
8042
+ app.db.delete(gaAiReferrals).where(eq16(gaAiReferrals.projectId, project.id)).run();
8023
8043
  store.deleteConnection(project.name);
8024
8044
  writeAuditLog(app.db, {
8025
8045
  projectId: project.id,
@@ -8038,7 +8058,7 @@ async function ga4Routes(app, opts) {
8038
8058
  if (!conn) {
8039
8059
  return { connected: false, propertyId: null, clientEmail: null, lastSyncedAt: null };
8040
8060
  }
8041
- const latestSync = app.db.select({ syncedAt: gaTrafficSnapshots.syncedAt }).from(gaTrafficSnapshots).where(eq16(gaTrafficSnapshots.projectId, project.id)).orderBy(desc6(gaTrafficSnapshots.syncedAt)).limit(1).get();
8061
+ const latestSync = app.db.select({ syncedAt: gaTrafficSummaries.syncedAt }).from(gaTrafficSummaries).where(eq16(gaTrafficSummaries.projectId, project.id)).orderBy(desc6(gaTrafficSummaries.syncedAt)).limit(1).get();
8042
8062
  return {
8043
8063
  connected: true,
8044
8064
  propertyId: conn.propertyId,
@@ -8069,11 +8089,13 @@ async function ga4Routes(app, opts) {
8069
8089
  }
8070
8090
  let rows;
8071
8091
  let summary;
8092
+ let aiReferrals;
8072
8093
  try {
8073
8094
  ;
8074
- [rows, summary] = await Promise.all([
8095
+ [rows, summary, aiReferrals] = await Promise.all([
8075
8096
  fetchTrafficByLandingPage(accessToken, conn.propertyId, days),
8076
- fetchAggregateSummary(accessToken, conn.propertyId, days)
8097
+ fetchAggregateSummary(accessToken, conn.propertyId, days),
8098
+ fetchAiReferrals(accessToken, conn.propertyId, days)
8077
8099
  ]);
8078
8100
  } catch (e) {
8079
8101
  const msg = e instanceof Error ? e.message : String(e);
@@ -8082,17 +8104,14 @@ async function ga4Routes(app, opts) {
8082
8104
  }
8083
8105
  const now = (/* @__PURE__ */ new Date()).toISOString();
8084
8106
  app.db.transaction((tx) => {
8107
+ tx.delete(gaTrafficSnapshots).where(
8108
+ and5(
8109
+ eq16(gaTrafficSnapshots.projectId, project.id),
8110
+ sql4`${gaTrafficSnapshots.date} >= ${summary.periodStart}`,
8111
+ sql4`${gaTrafficSnapshots.date} <= ${summary.periodEnd}`
8112
+ )
8113
+ ).run();
8085
8114
  if (rows.length > 0) {
8086
- const dates = rows.map((r) => r.date);
8087
- const minDate = dates.reduce((a, b) => a < b ? a : b);
8088
- const maxDate = dates.reduce((a, b) => a > b ? a : b);
8089
- tx.delete(gaTrafficSnapshots).where(
8090
- and6(
8091
- eq16(gaTrafficSnapshots.projectId, project.id),
8092
- sql3`${gaTrafficSnapshots.date} >= ${minDate}`,
8093
- sql3`${gaTrafficSnapshots.date} <= ${maxDate}`
8094
- )
8095
- ).run();
8096
8115
  for (const row of rows) {
8097
8116
  tx.insert(gaTrafficSnapshots).values({
8098
8117
  id: crypto16.randomUUID(),
@@ -8106,6 +8125,27 @@ async function ga4Routes(app, opts) {
8106
8125
  }).run();
8107
8126
  }
8108
8127
  }
8128
+ tx.delete(gaAiReferrals).where(
8129
+ and5(
8130
+ eq16(gaAiReferrals.projectId, project.id),
8131
+ sql4`${gaAiReferrals.date} >= ${summary.periodStart}`,
8132
+ sql4`${gaAiReferrals.date} <= ${summary.periodEnd}`
8133
+ )
8134
+ ).run();
8135
+ if (aiReferrals.length > 0) {
8136
+ for (const row of aiReferrals) {
8137
+ tx.insert(gaAiReferrals).values({
8138
+ id: crypto16.randomUUID(),
8139
+ projectId: project.id,
8140
+ date: row.date,
8141
+ source: row.source,
8142
+ medium: row.medium,
8143
+ sessions: row.sessions,
8144
+ users: row.users,
8145
+ syncedAt: now
8146
+ }).run();
8147
+ }
8148
+ }
8109
8149
  tx.delete(gaTrafficSummaries).where(eq16(gaTrafficSummaries.projectId, project.id)).run();
8110
8150
  tx.insert(gaTrafficSummaries).values({
8111
8151
  id: crypto16.randomUUID(),
@@ -8118,10 +8158,17 @@ async function ga4Routes(app, opts) {
8118
8158
  syncedAt: now
8119
8159
  }).run();
8120
8160
  });
8121
- gaLog("info", "sync.complete", { projectId: project.id, rowCount: rows.length, days, totalUsers: summary.totalUsers });
8161
+ gaLog("info", "sync.complete", {
8162
+ projectId: project.id,
8163
+ rowCount: rows.length,
8164
+ aiReferralCount: aiReferrals.length,
8165
+ days,
8166
+ totalUsers: summary.totalUsers
8167
+ });
8122
8168
  return {
8123
8169
  synced: true,
8124
8170
  rowCount: rows.length,
8171
+ aiReferralCount: aiReferrals.length,
8125
8172
  days,
8126
8173
  syncedAt: now
8127
8174
  };
@@ -8143,11 +8190,17 @@ async function ga4Routes(app, opts) {
8143
8190
  }).from(gaTrafficSummaries).where(eq16(gaTrafficSummaries.projectId, project.id)).get();
8144
8191
  const rows = app.db.select({
8145
8192
  landingPage: gaTrafficSnapshots.landingPage,
8146
- sessions: sql3`SUM(${gaTrafficSnapshots.sessions})`,
8147
- organicSessions: sql3`SUM(${gaTrafficSnapshots.organicSessions})`,
8148
- users: sql3`SUM(${gaTrafficSnapshots.users})`
8149
- }).from(gaTrafficSnapshots).where(eq16(gaTrafficSnapshots.projectId, project.id)).groupBy(gaTrafficSnapshots.landingPage).orderBy(sql3`SUM(${gaTrafficSnapshots.sessions}) DESC`).limit(limit).all();
8150
- const latestSync = app.db.select({ syncedAt: gaTrafficSnapshots.syncedAt }).from(gaTrafficSnapshots).where(eq16(gaTrafficSnapshots.projectId, project.id)).orderBy(desc6(gaTrafficSnapshots.syncedAt)).limit(1).get();
8193
+ sessions: sql4`SUM(${gaTrafficSnapshots.sessions})`,
8194
+ organicSessions: sql4`SUM(${gaTrafficSnapshots.organicSessions})`,
8195
+ users: sql4`SUM(${gaTrafficSnapshots.users})`
8196
+ }).from(gaTrafficSnapshots).where(eq16(gaTrafficSnapshots.projectId, project.id)).groupBy(gaTrafficSnapshots.landingPage).orderBy(sql4`SUM(${gaTrafficSnapshots.sessions}) DESC`).limit(limit).all();
8197
+ const aiReferrals = app.db.select({
8198
+ source: gaAiReferrals.source,
8199
+ medium: gaAiReferrals.medium,
8200
+ sessions: sql4`SUM(${gaAiReferrals.sessions})`,
8201
+ users: sql4`SUM(${gaAiReferrals.users})`
8202
+ }).from(gaAiReferrals).where(eq16(gaAiReferrals.projectId, project.id)).groupBy(gaAiReferrals.source, gaAiReferrals.medium).orderBy(sql4`SUM(${gaAiReferrals.sessions}) DESC`).all();
8203
+ const latestSync = app.db.select({ syncedAt: gaTrafficSummaries.syncedAt }).from(gaTrafficSummaries).where(eq16(gaTrafficSummaries.projectId, project.id)).orderBy(desc6(gaTrafficSummaries.syncedAt)).limit(1).get();
8151
8204
  return {
8152
8205
  totalSessions: summary?.totalSessions ?? 0,
8153
8206
  totalOrganicSessions: summary?.totalOrganicSessions ?? 0,
@@ -8158,6 +8211,12 @@ async function ga4Routes(app, opts) {
8158
8211
  organicSessions: r.organicSessions ?? 0,
8159
8212
  users: r.users ?? 0
8160
8213
  })),
8214
+ aiReferrals: aiReferrals.map((r) => ({
8215
+ source: r.source,
8216
+ medium: r.medium,
8217
+ sessions: r.sessions ?? 0,
8218
+ users: r.users ?? 0
8219
+ })),
8161
8220
  lastSyncedAt: latestSync?.syncedAt ?? null
8162
8221
  };
8163
8222
  });
@@ -8172,10 +8231,10 @@ async function ga4Routes(app, opts) {
8172
8231
  }
8173
8232
  const trafficPages = app.db.select({
8174
8233
  landingPage: gaTrafficSnapshots.landingPage,
8175
- sessions: sql3`SUM(${gaTrafficSnapshots.sessions})`,
8176
- organicSessions: sql3`SUM(${gaTrafficSnapshots.organicSessions})`,
8177
- users: sql3`SUM(${gaTrafficSnapshots.users})`
8178
- }).from(gaTrafficSnapshots).where(eq16(gaTrafficSnapshots.projectId, project.id)).groupBy(gaTrafficSnapshots.landingPage).orderBy(sql3`SUM(${gaTrafficSnapshots.sessions}) DESC`).all();
8234
+ sessions: sql4`SUM(${gaTrafficSnapshots.sessions})`,
8235
+ organicSessions: sql4`SUM(${gaTrafficSnapshots.organicSessions})`,
8236
+ users: sql4`SUM(${gaTrafficSnapshots.users})`
8237
+ }).from(gaTrafficSnapshots).where(eq16(gaTrafficSnapshots.projectId, project.id)).groupBy(gaTrafficSnapshots.landingPage).orderBy(sql4`SUM(${gaTrafficSnapshots.sessions}) DESC`).all();
8179
8238
  return {
8180
8239
  pages: trafficPages.map((r) => ({
8181
8240
  landingPage: r.landingPage,
@@ -8199,6 +8258,115 @@ var WordpressApiError = class extends Error {
8199
8258
  }
8200
8259
  };
8201
8260
 
8261
+ // ../integration-wordpress/src/schema-templates.ts
8262
+ var SUPPORTED_TYPES = /* @__PURE__ */ new Set([
8263
+ "LocalBusiness",
8264
+ "Organization",
8265
+ "FAQPage",
8266
+ "Service",
8267
+ "WebPage"
8268
+ ]);
8269
+ function isSupportedSchemaType(type) {
8270
+ return SUPPORTED_TYPES.has(type);
8271
+ }
8272
+ function buildAddress(address) {
8273
+ return {
8274
+ "@type": "PostalAddress",
8275
+ ...address.street ? { streetAddress: address.street } : {},
8276
+ ...address.city ? { addressLocality: address.city } : {},
8277
+ ...address.state ? { addressRegion: address.state } : {},
8278
+ ...address.zip ? { postalCode: address.zip } : {},
8279
+ ...address.country ? { addressCountry: address.country } : {}
8280
+ };
8281
+ }
8282
+ function generateLocalBusiness(profile) {
8283
+ const schema = {
8284
+ "@context": "https://schema.org",
8285
+ "@type": "LocalBusiness",
8286
+ name: profile.name
8287
+ };
8288
+ if (profile.url) schema.url = profile.url;
8289
+ if (profile.description) schema.description = profile.description;
8290
+ if (profile.phone) schema.telephone = profile.phone;
8291
+ if (profile.email) schema.email = profile.email;
8292
+ if (profile.address) schema.address = buildAddress(profile.address);
8293
+ return schema;
8294
+ }
8295
+ function generateOrganization(profile) {
8296
+ const schema = {
8297
+ "@context": "https://schema.org",
8298
+ "@type": "Organization",
8299
+ name: profile.name
8300
+ };
8301
+ if (profile.url) schema.url = profile.url;
8302
+ if (profile.description) schema.description = profile.description;
8303
+ if (profile.phone) schema.telephone = profile.phone;
8304
+ if (profile.email) schema.email = profile.email;
8305
+ if (profile.address) schema.address = buildAddress(profile.address);
8306
+ return schema;
8307
+ }
8308
+ function generateFAQPage(profile, faqs) {
8309
+ const schema = {
8310
+ "@context": "https://schema.org",
8311
+ "@type": "FAQPage",
8312
+ name: profile.name
8313
+ };
8314
+ if (faqs && faqs.length > 0) {
8315
+ schema.mainEntity = faqs.map((faq) => ({
8316
+ "@type": "Question",
8317
+ name: faq.q,
8318
+ acceptedAnswer: {
8319
+ "@type": "Answer",
8320
+ text: faq.a
8321
+ }
8322
+ }));
8323
+ }
8324
+ return schema;
8325
+ }
8326
+ function generateService(profile) {
8327
+ const schema = {
8328
+ "@context": "https://schema.org",
8329
+ "@type": "Service",
8330
+ name: profile.name
8331
+ };
8332
+ if (profile.url) schema.url = profile.url;
8333
+ if (profile.description) schema.description = profile.description;
8334
+ if (profile.address) schema.areaServed = buildAddress(profile.address);
8335
+ return schema;
8336
+ }
8337
+ function generateWebPage(profile) {
8338
+ const schema = {
8339
+ "@context": "https://schema.org",
8340
+ "@type": "WebPage",
8341
+ name: profile.name
8342
+ };
8343
+ if (profile.url) schema.url = profile.url;
8344
+ if (profile.description) schema.description = profile.description;
8345
+ return schema;
8346
+ }
8347
+ function generateSchema(type, profile, overrides) {
8348
+ switch (type) {
8349
+ case "LocalBusiness":
8350
+ return generateLocalBusiness(profile);
8351
+ case "Organization":
8352
+ return generateOrganization(profile);
8353
+ case "FAQPage":
8354
+ return generateFAQPage(profile, overrides?.faqs);
8355
+ case "Service":
8356
+ return generateService(profile);
8357
+ case "WebPage":
8358
+ return generateWebPage(profile);
8359
+ default:
8360
+ throw new Error(`Unsupported schema type: ${type}`);
8361
+ }
8362
+ }
8363
+ function parseSchemaPageEntry(entry) {
8364
+ if (typeof entry === "string") {
8365
+ return { type: entry };
8366
+ }
8367
+ return { type: entry.type, faqs: entry.faqs };
8368
+ }
8369
+
8202
8370
  // ../integration-wordpress/src/wordpress-client.ts
8203
8371
  import crypto17 from "crypto";
8204
8372
  var PAGE_FIELDS = "id,slug,status,link,modified,modified_gmt,title,content,meta";
@@ -8635,6 +8803,271 @@ async function setSeoMeta(connection, slug, body, env) {
8635
8803
  );
8636
8804
  return getPageDetail(connection, slug, site.env, plugins);
8637
8805
  }
8806
+ async function detectSeoWriteStrategy(connection, env) {
8807
+ const site = resolveEnvironment(connection, env);
8808
+ const plugins = await listActivePlugins(connection, site.env);
8809
+ const pages = await listPages(connection, site.env);
8810
+ if (pages.length === 0) {
8811
+ return { strategy: "manual", plugins };
8812
+ }
8813
+ const samplePage = await getPageBySlug(connection, pages[0].slug, site.env);
8814
+ const writeTargets = resolveSeoWriteTargets(samplePage.meta, plugins);
8815
+ return {
8816
+ strategy: writeTargets.length > 0 ? "plugin" : "manual",
8817
+ plugins
8818
+ };
8819
+ }
8820
+ function buildManualMetaAssist(siteUrl, slug, link, meta) {
8821
+ const fields = [];
8822
+ if (meta.title != null) fields.push(`Title: ${meta.title}`);
8823
+ if (meta.description != null) fields.push(`Description: ${meta.description}`);
8824
+ if (meta.noindex != null) fields.push(`Noindex: ${meta.noindex}`);
8825
+ return {
8826
+ manualRequired: true,
8827
+ targetUrl: link ?? `${siteUrl}/${slug}`,
8828
+ adminUrl: `${siteUrl}/wp-admin/`,
8829
+ content: fields.join("\n"),
8830
+ nextSteps: [
8831
+ `Open the WordPress editor for page "${slug}".`,
8832
+ "Install an SEO plugin (Yoast SEO, Rank Math, or AIOSEO) to manage meta fields via REST, or set the values manually in the page editor.",
8833
+ "Apply the meta values listed above.",
8834
+ "Publish/update the page."
8835
+ ]
8836
+ };
8837
+ }
8838
+ async function bulkSetSeoMeta(connection, entries, env) {
8839
+ const site = resolveEnvironment(connection, env);
8840
+ const { strategy, plugins } = await detectSeoWriteStrategy(connection, site.env);
8841
+ const results = await mapWithConcurrency(
8842
+ entries,
8843
+ 3,
8844
+ async (entry) => {
8845
+ try {
8846
+ const page = await getPageBySlug(connection, entry.slug, site.env);
8847
+ if (strategy === "manual") {
8848
+ return {
8849
+ slug: entry.slug,
8850
+ status: "manual",
8851
+ manualAssist: buildManualMetaAssist(
8852
+ site.siteUrl,
8853
+ entry.slug,
8854
+ page.link,
8855
+ entry
8856
+ )
8857
+ };
8858
+ }
8859
+ const writeTargets = resolveSeoWriteTargets(page.meta, plugins);
8860
+ if (writeTargets.length === 0 || !page.meta) {
8861
+ return {
8862
+ slug: entry.slug,
8863
+ status: "manual",
8864
+ manualAssist: buildManualMetaAssist(
8865
+ site.siteUrl,
8866
+ entry.slug,
8867
+ page.link,
8868
+ entry
8869
+ )
8870
+ };
8871
+ }
8872
+ const patch = {};
8873
+ for (const target of SEO_TARGETS) {
8874
+ if (entry.title != null && writeTargets.includes(target.titleKey)) patch[target.titleKey] = entry.title;
8875
+ if (entry.description != null && writeTargets.includes(target.descriptionKey)) patch[target.descriptionKey] = entry.description;
8876
+ if (entry.noindex != null && writeTargets.includes(target.noindexKey)) {
8877
+ patch[target.noindexKey] = encodeNoindexValue(target.noindexKey, entry.noindex);
8878
+ }
8879
+ }
8880
+ if (Object.keys(patch).length === 0) {
8881
+ return {
8882
+ slug: entry.slug,
8883
+ status: "manual",
8884
+ manualAssist: buildManualMetaAssist(
8885
+ site.siteUrl,
8886
+ entry.slug,
8887
+ page.link,
8888
+ entry
8889
+ )
8890
+ };
8891
+ }
8892
+ await fetchJson(
8893
+ connection,
8894
+ site.siteUrl,
8895
+ `/wp-json/wp/v2/pages/${page.id}`,
8896
+ {
8897
+ method: "POST",
8898
+ body: JSON.stringify({
8899
+ meta: { ...page.meta ?? {}, ...patch }
8900
+ })
8901
+ }
8902
+ );
8903
+ return { slug: entry.slug, status: "applied" };
8904
+ } catch (error) {
8905
+ if (error instanceof WordpressApiError && error.code === "NOT_FOUND") {
8906
+ return { slug: entry.slug, status: "skipped", error: `Page "${entry.slug}" not found` };
8907
+ }
8908
+ return {
8909
+ slug: entry.slug,
8910
+ status: "skipped",
8911
+ error: error instanceof Error ? error.message : String(error)
8912
+ };
8913
+ }
8914
+ }
8915
+ );
8916
+ return { env: site.env, strategy, results };
8917
+ }
8918
+ var CANONRY_SCHEMA_START = "<!-- canonry:schema:start -->";
8919
+ var CANONRY_SCHEMA_END = "<!-- canonry:schema:end -->";
8920
+ function stripCanonrySchema(content) {
8921
+ const regex = new RegExp(
8922
+ `${escapeRegExp(CANONRY_SCHEMA_START)}[\\s\\S]*?${escapeRegExp(CANONRY_SCHEMA_END)}`,
8923
+ "g"
8924
+ );
8925
+ return content.replace(regex, "").replace(/\n{3,}/g, "\n\n").trim();
8926
+ }
8927
+ function escapeRegExp(str) {
8928
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
8929
+ }
8930
+ function injectCanonrySchema(content, schemas) {
8931
+ if (schemas.length === 0) return content;
8932
+ const blocks = schemas.map((schema) => `<script type="application/ld+json">${JSON.stringify(schema).replace(/<\//g, "<\\/")}</script>`).join("\n");
8933
+ const injection = `
8934
+
8935
+ ${CANONRY_SCHEMA_START}
8936
+ ${blocks}
8937
+ ${CANONRY_SCHEMA_END}`;
8938
+ const stripped = stripCanonrySchema(content);
8939
+ return stripped + injection;
8940
+ }
8941
+ async function verifySchemaInjection(connection, slug, env) {
8942
+ const page = await getPageBySlug(connection, slug, env);
8943
+ const raw = page.content?.raw ?? page.content?.rendered ?? "";
8944
+ return raw.includes(CANONRY_SCHEMA_START);
8945
+ }
8946
+ async function deploySchema(connection, slug, schemas, env) {
8947
+ const site = resolveEnvironment(connection, env);
8948
+ try {
8949
+ const page = await getPageBySlug(connection, slug, site.env);
8950
+ const currentContent = page.content?.raw ?? page.content?.rendered ?? "";
8951
+ const updatedContent = injectCanonrySchema(currentContent, schemas);
8952
+ await fetchJson(
8953
+ connection,
8954
+ site.siteUrl,
8955
+ `/wp-json/wp/v2/pages/${page.id}`,
8956
+ {
8957
+ method: "POST",
8958
+ body: JSON.stringify({ content: updatedContent })
8959
+ }
8960
+ );
8961
+ const persisted = await verifySchemaInjection(connection, slug, site.env);
8962
+ if (!persisted) {
8963
+ return {
8964
+ slug,
8965
+ status: "stripped",
8966
+ schemasInjected: schemas.map((s) => String(s["@type"] ?? "Unknown")),
8967
+ manualAssist: {
8968
+ manualRequired: true,
8969
+ targetUrl: page.link ?? `${site.siteUrl}/${slug}`,
8970
+ adminUrl: `${site.siteUrl}/wp-admin/`,
8971
+ content: schemas.map((s) => JSON.stringify(s, null, 2)).join("\n\n"),
8972
+ nextSteps: [
8973
+ `WordPress stripped the schema <script> tags for page "${slug}". The connected user likely lacks the unfiltered_html capability.`,
8974
+ "Grant the user Administrator or Super Admin role, or add the schema manually in the page editor or via a schema plugin.",
8975
+ "Paste the JSON-LD blocks provided above."
8976
+ ]
8977
+ }
8978
+ };
8979
+ }
8980
+ return {
8981
+ slug,
8982
+ status: "deployed",
8983
+ schemasInjected: schemas.map((s) => String(s["@type"] ?? "Unknown"))
8984
+ };
8985
+ } catch (error) {
8986
+ if (error instanceof WordpressApiError && error.code === "NOT_FOUND") {
8987
+ return { slug, status: "skipped", error: `Page "${slug}" not found` };
8988
+ }
8989
+ return {
8990
+ slug,
8991
+ status: "failed",
8992
+ error: error instanceof Error ? error.message : String(error)
8993
+ };
8994
+ }
8995
+ }
8996
+ async function deploySchemaFromProfile(connection, profile, env) {
8997
+ const site = resolveEnvironment(connection, env);
8998
+ const slugEntries = Object.entries(profile.pages);
8999
+ const results = await mapWithConcurrency(
9000
+ slugEntries,
9001
+ 3,
9002
+ async ([slug, entries]) => {
9003
+ const parsed = entries.map(parseSchemaPageEntry);
9004
+ const unsupported = parsed.filter((p) => !isSupportedSchemaType(p.type));
9005
+ if (unsupported.length > 0) {
9006
+ return {
9007
+ slug,
9008
+ status: "failed",
9009
+ error: `Unsupported schema type(s): ${unsupported.map((u) => u.type).join(", ")}`
9010
+ };
9011
+ }
9012
+ const schemas = parsed.map((p) => generateSchema(p.type, profile.business, { faqs: p.faqs }));
9013
+ return deploySchema(connection, slug, schemas, site.env);
9014
+ }
9015
+ );
9016
+ return { env: site.env, results };
9017
+ }
9018
+ async function getSchemaStatus(connection, env) {
9019
+ const site = resolveEnvironment(connection, env);
9020
+ const pages = await listPages(connection, site.env);
9021
+ const details = await mapWithConcurrency(
9022
+ pages,
9023
+ 5,
9024
+ async (page) => getPageDetail(connection, page.slug, site.env)
9025
+ );
9026
+ const statusPages = details.map((page) => {
9027
+ const rawContent = page.content;
9028
+ const hasCanonryMarker = rawContent.includes(CANONRY_SCHEMA_START);
9029
+ const allSchemaTypes = page.schemaBlocks.map((b) => b.type);
9030
+ const canonrySchemas = [];
9031
+ const thirdPartySchemas = [];
9032
+ if (hasCanonryMarker) {
9033
+ const markerRegex = new RegExp(
9034
+ `${escapeRegExp(CANONRY_SCHEMA_START)}([\\s\\S]*?)${escapeRegExp(CANONRY_SCHEMA_END)}`
9035
+ );
9036
+ const match = markerRegex.exec(rawContent);
9037
+ if (match?.[1]) {
9038
+ const jsonLdRegex = /<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
9039
+ let jsonMatch;
9040
+ while ((jsonMatch = jsonLdRegex.exec(match[1])) !== null) {
9041
+ try {
9042
+ const parsed = JSON.parse(jsonMatch[1].trim());
9043
+ canonrySchemas.push(String(parsed["@type"] ?? "Unknown"));
9044
+ } catch {
9045
+ }
9046
+ }
9047
+ }
9048
+ }
9049
+ const canonryCounts = /* @__PURE__ */ new Map();
9050
+ for (const t of canonrySchemas) {
9051
+ canonryCounts.set(t, (canonryCounts.get(t) ?? 0) + 1);
9052
+ }
9053
+ for (const schemaType of allSchemaTypes) {
9054
+ const remaining = canonryCounts.get(schemaType) ?? 0;
9055
+ if (remaining > 0) {
9056
+ canonryCounts.set(schemaType, remaining - 1);
9057
+ } else {
9058
+ thirdPartySchemas.push(schemaType);
9059
+ }
9060
+ }
9061
+ return {
9062
+ slug: page.slug,
9063
+ title: page.title,
9064
+ canonrySchemas,
9065
+ thirdPartySchemas,
9066
+ hasCanonrySchema: hasCanonryMarker
9067
+ };
9068
+ });
9069
+ return { env: site.env, pages: statusPages };
9070
+ }
8638
9071
  async function getLlmsTxt(connection, env) {
8639
9072
  const site = resolveEnvironment(connection, env);
8640
9073
  const url = `${site.siteUrl}/llms.txt`;
@@ -9080,6 +9513,39 @@ async function wordpressRoutes(app, opts) {
9080
9513
  return updated;
9081
9514
  });
9082
9515
  });
9516
+ app.post("/projects/:name/wordpress/pages/meta/bulk", async (request, reply) => {
9517
+ return withWordpressErrorHandling(reply, async () => {
9518
+ const store = requireStore(reply);
9519
+ if (!store) return;
9520
+ const project = resolveProject(app.db, request.params.name);
9521
+ const connection = requireConnection(store, project.name, reply);
9522
+ if (!connection) return;
9523
+ const entries = request.body?.entries;
9524
+ if (!Array.isArray(entries) || entries.length === 0) {
9525
+ const err = validationError("entries array is required and must not be empty");
9526
+ return reply.status(err.statusCode).send(err.toJSON());
9527
+ }
9528
+ for (const entry of entries) {
9529
+ if (!entry.slug?.trim()) {
9530
+ const err = validationError("each entry must have a slug");
9531
+ return reply.status(err.statusCode).send(err.toJSON());
9532
+ }
9533
+ }
9534
+ const env = parseEnvInput(request.body?.env);
9535
+ const result = await bulkSetSeoMeta(connection, entries, env);
9536
+ const applied = result.results.filter((r) => r.status === "applied");
9537
+ if (applied.length > 0) {
9538
+ writeAuditLog(app.db, {
9539
+ projectId: project.id,
9540
+ actor: "api",
9541
+ action: "wordpress.page-meta-updated",
9542
+ entityType: "wordpress_page",
9543
+ entityId: `bulk(${applied.map((r) => r.slug).join(",")})`
9544
+ });
9545
+ }
9546
+ return result;
9547
+ });
9548
+ });
9083
9549
  app.get("/projects/:name/wordpress/schema", async (request, reply) => {
9084
9550
  return withWordpressErrorHandling(reply, async () => {
9085
9551
  const store = requireStore(reply);
@@ -9113,6 +9579,33 @@ async function wordpressRoutes(app, opts) {
9113
9579
  return buildManualSchemaUpdate(connection, slug, { type: request.body?.type, json }, env);
9114
9580
  });
9115
9581
  });
9582
+ app.post("/projects/:name/wordpress/schema/deploy", async (request, reply) => {
9583
+ return withWordpressErrorHandling(reply, async () => {
9584
+ const store = requireStore(reply);
9585
+ if (!store) return;
9586
+ const project = resolveProject(app.db, request.params.name);
9587
+ const connection = requireConnection(store, project.name, reply);
9588
+ if (!connection) return;
9589
+ const profile = request.body?.profile;
9590
+ if (!profile?.business?.name || !profile?.pages || Object.keys(profile.pages).length === 0) {
9591
+ const err = validationError("profile with business.name and non-empty pages is required");
9592
+ return reply.status(err.statusCode).send(err.toJSON());
9593
+ }
9594
+ const env = parseEnvInput(request.body?.env);
9595
+ return deploySchemaFromProfile(connection, profile, env);
9596
+ });
9597
+ });
9598
+ app.get("/projects/:name/wordpress/schema/status", async (request, reply) => {
9599
+ return withWordpressErrorHandling(reply, async () => {
9600
+ const store = requireStore(reply);
9601
+ if (!store) return;
9602
+ const project = resolveProject(app.db, request.params.name);
9603
+ const connection = requireConnection(store, project.name, reply);
9604
+ if (!connection) return;
9605
+ const env = parseEnvInput(request.query?.env);
9606
+ return getSchemaStatus(connection, env);
9607
+ });
9608
+ });
9116
9609
  app.get("/projects/:name/wordpress/llms-txt", async (request, reply) => {
9117
9610
  return withWordpressErrorHandling(reply, async () => {
9118
9611
  const store = requireStore(reply);
@@ -9196,6 +9689,201 @@ async function wordpressRoutes(app, opts) {
9196
9689
  return buildManualStagingPush(connection);
9197
9690
  });
9198
9691
  });
9692
+ app.post("/projects/:name/wordpress/onboard", async (request, reply) => {
9693
+ return withWordpressErrorHandling(reply, async () => {
9694
+ const store = requireStore(reply);
9695
+ if (!store) return;
9696
+ const project = resolveProject(app.db, request.params.name);
9697
+ const { url, username, appPassword, stagingUrl, profile, skipSchema, skipSubmit } = request.body ?? {};
9698
+ if (!url || !username || !appPassword) {
9699
+ const err = validationError("url, username, and appPassword are required");
9700
+ return reply.status(err.statusCode).send(err.toJSON());
9701
+ }
9702
+ const defaultEnv = parseEnvInput(request.body?.defaultEnv, "defaultEnv") ?? (stagingUrl ? "staging" : "live");
9703
+ if (defaultEnv === "staging" && !stagingUrl) {
9704
+ const err = validationError('defaultEnv "staging" requires stagingUrl');
9705
+ return reply.status(err.statusCode).send(err.toJSON());
9706
+ }
9707
+ const steps = [];
9708
+ let connection = null;
9709
+ let pageUrls = [];
9710
+ try {
9711
+ const now = (/* @__PURE__ */ new Date()).toISOString();
9712
+ const existing = store.getConnection(project.name);
9713
+ const nextConnection = {
9714
+ projectName: project.name,
9715
+ url,
9716
+ stagingUrl,
9717
+ username,
9718
+ appPassword,
9719
+ defaultEnv,
9720
+ createdAt: existing?.createdAt ?? now,
9721
+ updatedAt: now
9722
+ };
9723
+ await verifyWordpressConnection(nextConnection);
9724
+ connection = store.upsertConnection(nextConnection);
9725
+ writeAuditLog(app.db, {
9726
+ projectId: project.id,
9727
+ actor: "api",
9728
+ action: "wordpress.connected",
9729
+ entityType: "wordpress_connection",
9730
+ entityId: project.name
9731
+ });
9732
+ steps.push({ name: "connect", status: "completed", summary: `Connected to ${url}` });
9733
+ } catch (err) {
9734
+ const msg = err instanceof Error ? err.message : String(err);
9735
+ steps.push({ name: "connect", status: "failed", error: msg });
9736
+ return { projectName: project.name, steps };
9737
+ }
9738
+ let auditIssues = [];
9739
+ let auditPages = [];
9740
+ try {
9741
+ const audit = await runAudit(connection);
9742
+ const issueCount = audit.issues?.length ?? 0;
9743
+ const pageCount = audit.pages?.length ?? 0;
9744
+ auditIssues = audit.issues;
9745
+ auditPages = audit.pages;
9746
+ const pageSummaries = await listPages(connection);
9747
+ pageUrls = pageSummaries.map((p) => p.link).filter((link) => typeof link === "string" && link.length > 0);
9748
+ steps.push({ name: "audit", status: "completed", summary: `${pageCount} pages audited, ${issueCount} issues` });
9749
+ } catch (err) {
9750
+ const msg = err instanceof Error ? err.message : String(err);
9751
+ steps.push({ name: "audit", status: "failed", error: msg });
9752
+ return { projectName: project.name, steps };
9753
+ }
9754
+ try {
9755
+ const metaEntries = [];
9756
+ for (const issue of auditIssues) {
9757
+ if (issue.code === "missing-meta-description" || issue.code === "missing-seo-title") {
9758
+ const existing = metaEntries.find((e) => e.slug === issue.slug);
9759
+ const page = auditPages.find((p) => p.slug === issue.slug);
9760
+ if (!existing) {
9761
+ metaEntries.push({
9762
+ slug: issue.slug,
9763
+ title: issue.code === "missing-seo-title" ? page?.title ?? issue.slug : void 0,
9764
+ description: issue.code === "missing-meta-description" ? page?.title ?? issue.slug : void 0
9765
+ });
9766
+ } else {
9767
+ if (issue.code === "missing-seo-title" && !existing.title) {
9768
+ existing.title = page?.title ?? issue.slug;
9769
+ }
9770
+ if (issue.code === "missing-meta-description" && !existing.description) {
9771
+ existing.description = page?.title ?? issue.slug;
9772
+ }
9773
+ }
9774
+ }
9775
+ }
9776
+ if (metaEntries.length === 0) {
9777
+ steps.push({ name: "set-meta", status: "skipped", summary: "No pages with missing meta found" });
9778
+ } else {
9779
+ const result = await bulkSetSeoMeta(connection, metaEntries);
9780
+ const applied = result.results.filter((r) => r.status === "applied").length;
9781
+ const manual = result.results.filter((r) => r.status === "manual").length;
9782
+ const skipped = result.results.filter((r) => r.status === "skipped").length;
9783
+ steps.push({
9784
+ name: "set-meta",
9785
+ status: "completed",
9786
+ summary: `${applied} applied, ${manual} manual-assist, ${skipped} skipped`
9787
+ });
9788
+ }
9789
+ } catch (err) {
9790
+ const msg = err instanceof Error ? err.message : String(err);
9791
+ steps.push({ name: "set-meta", status: "failed", error: msg });
9792
+ return { projectName: project.name, steps };
9793
+ }
9794
+ if (skipSchema || !profile) {
9795
+ steps.push({
9796
+ name: "schema-deploy",
9797
+ status: "skipped",
9798
+ summary: skipSchema ? "Skipped via --skip-schema" : "No --profile provided"
9799
+ });
9800
+ } else {
9801
+ try {
9802
+ if (!profile.business?.name || !profile.pages || Object.keys(profile.pages).length === 0) {
9803
+ steps.push({ name: "schema-deploy", status: "skipped", summary: "Profile missing business.name or pages" });
9804
+ } else {
9805
+ const result = await deploySchemaFromProfile(connection, profile);
9806
+ const deployed = result.results.filter((r) => r.status === "deployed").length;
9807
+ const stripped = result.results.filter((r) => r.status === "stripped").length;
9808
+ const skipped = result.results.filter((r) => r.status === "skipped").length;
9809
+ steps.push({
9810
+ name: "schema-deploy",
9811
+ status: "completed",
9812
+ summary: `${deployed} deployed, ${stripped} stripped (manual-assist), ${skipped} skipped`
9813
+ });
9814
+ }
9815
+ } catch (err) {
9816
+ const msg = err instanceof Error ? err.message : String(err);
9817
+ steps.push({ name: "schema-deploy", status: "failed", error: msg });
9818
+ return { projectName: project.name, steps };
9819
+ }
9820
+ }
9821
+ if (skipSubmit || pageUrls.length === 0) {
9822
+ const reason = skipSubmit ? "Skipped via --skip-submit" : "No page URLs to submit";
9823
+ steps.push({ name: "google-submit", status: "skipped", summary: reason });
9824
+ steps.push({ name: "bing-submit", status: "skipped", summary: reason });
9825
+ } else {
9826
+ try {
9827
+ const authHeader = request.headers.authorization;
9828
+ const googleRes = await app.inject({
9829
+ method: "POST",
9830
+ url: `${opts.routePrefix ?? "/api/v1"}/projects/${encodeURIComponent(project.name)}/google/indexing/request`,
9831
+ payload: { urls: pageUrls },
9832
+ headers: authHeader ? { authorization: authHeader } : {}
9833
+ });
9834
+ if (googleRes.statusCode === 200) {
9835
+ const body = JSON.parse(googleRes.body);
9836
+ const succeeded = body.results?.filter((r) => r.status === "success").length ?? 0;
9837
+ steps.push({ name: "google-submit", status: "completed", summary: `${succeeded}/${pageUrls.length} URLs submitted` });
9838
+ } else {
9839
+ const body = JSON.parse(googleRes.body);
9840
+ const msg = body.message || body.error || `HTTP ${googleRes.statusCode}`;
9841
+ if (googleRes.statusCode === 400 || googleRes.statusCode === 404) {
9842
+ steps.push({ name: "google-submit", status: "skipped", summary: msg });
9843
+ } else {
9844
+ steps.push({ name: "google-submit", status: "failed", error: msg });
9845
+ }
9846
+ }
9847
+ } catch (err) {
9848
+ const msg = err instanceof Error ? err.message : String(err);
9849
+ steps.push({ name: "google-submit", status: "skipped", summary: `Google not available: ${msg}` });
9850
+ }
9851
+ try {
9852
+ const authHeader = request.headers.authorization;
9853
+ const bingRes = await app.inject({
9854
+ method: "POST",
9855
+ url: `${opts.routePrefix ?? "/api/v1"}/projects/${encodeURIComponent(project.name)}/bing/request-indexing`,
9856
+ payload: { urls: pageUrls },
9857
+ headers: authHeader ? { authorization: authHeader } : {}
9858
+ });
9859
+ if (bingRes.statusCode === 200) {
9860
+ const body = JSON.parse(bingRes.body);
9861
+ const succeeded = body.results?.filter((r) => r.status === "success").length ?? 0;
9862
+ steps.push({ name: "bing-submit", status: "completed", summary: `${succeeded}/${pageUrls.length} URLs submitted` });
9863
+ } else {
9864
+ const body = JSON.parse(bingRes.body);
9865
+ const msg = body.message || body.error || `HTTP ${bingRes.statusCode}`;
9866
+ if (bingRes.statusCode === 400 || bingRes.statusCode === 404) {
9867
+ steps.push({ name: "bing-submit", status: "skipped", summary: msg });
9868
+ } else {
9869
+ steps.push({ name: "bing-submit", status: "failed", error: msg });
9870
+ }
9871
+ }
9872
+ } catch (err) {
9873
+ const msg = err instanceof Error ? err.message : String(err);
9874
+ steps.push({ name: "bing-submit", status: "skipped", summary: `Bing not available: ${msg}` });
9875
+ }
9876
+ }
9877
+ writeAuditLog(app.db, {
9878
+ projectId: project.id,
9879
+ actor: "api",
9880
+ action: "wordpress.onboarded",
9881
+ entityType: "wordpress_connection",
9882
+ entityId: project.name
9883
+ });
9884
+ return { projectName: project.name, steps };
9885
+ });
9886
+ });
9199
9887
  }
9200
9888
 
9201
9889
  // ../api-routes/src/index.ts
@@ -9288,7 +9976,8 @@ async function apiRoutes(app, opts) {
9288
9976
  onInspectSitemapRequested: opts.onInspectSitemapRequested
9289
9977
  });
9290
9978
  await api.register(wordpressRoutes, {
9291
- wordpressConnectionStore: opts.wordpressConnectionStore
9979
+ wordpressConnectionStore: opts.wordpressConnectionStore,
9980
+ routePrefix: opts.routePrefix ?? "/api/v1"
9292
9981
  });
9293
9982
  await api.register(cdpRoutes, {
9294
9983
  getCdpStatus: opts.getCdpStatus,
@@ -11251,7 +11940,7 @@ import crypto18 from "crypto";
11251
11940
  import fs4 from "fs";
11252
11941
  import path5 from "path";
11253
11942
  import os4 from "os";
11254
- import { and as and7, eq as eq17, inArray as inArray3 } from "drizzle-orm";
11943
+ import { and as and6, eq as eq17, inArray as inArray3 } from "drizzle-orm";
11255
11944
 
11256
11945
  // src/logger.ts
11257
11946
  var IS_TTY = process.stdout.isTTY === true;
@@ -11401,7 +12090,7 @@ var JobRunner = class {
11401
12090
  throw new Error(`Run ${runId} is not executable from status '${existingRun.status}'`);
11402
12091
  }
11403
12092
  if (existingRun.status === "queued") {
11404
- this.db.update(runs).set({ status: "running", startedAt: now }).where(and7(eq17(runs.id, runId), eq17(runs.status, "queued"))).run();
12093
+ this.db.update(runs).set({ status: "running", startedAt: now }).where(and6(eq17(runs.id, runId), eq17(runs.status, "queued"))).run();
11405
12094
  }
11406
12095
  this.throwIfRunCancelled(runId);
11407
12096
  const project = this.db.select().from(projects).where(eq17(projects.id, projectId)).get();
@@ -11488,6 +12177,12 @@ var JobRunner = class {
11488
12177
  log.info("query.result", { runId, provider: providerName, keyword: kw.keyword, citedDomains: normalized.citedDomains, groundingSources: normalized.groundingSources.map((s) => s.uri), matchDomains: allDomains });
11489
12178
  const citationState = determineCitationState(normalized, allDomains);
11490
12179
  const overlap = computeCompetitorOverlap(normalized, competitorDomains);
12180
+ const extractedCompetitors = extractRecommendedCompetitors(
12181
+ normalized.answerText,
12182
+ allDomains,
12183
+ normalized.citedDomains,
12184
+ overlap
12185
+ );
11491
12186
  let screenshotRelPath = null;
11492
12187
  if (raw.screenshotPath && fs4.existsSync(raw.screenshotPath)) {
11493
12188
  const snapshotId = crypto18.randomUUID();
@@ -11506,6 +12201,7 @@ var JobRunner = class {
11506
12201
  answerText: normalized.answerText,
11507
12202
  citedDomains: JSON.stringify(normalized.citedDomains),
11508
12203
  competitorOverlap: JSON.stringify(overlap),
12204
+ recommendedCompetitors: JSON.stringify(extractedCompetitors),
11509
12205
  location: runLocation?.label ?? null,
11510
12206
  screenshotPath: screenshotRelPath,
11511
12207
  rawResponse: JSON.stringify({
@@ -11527,6 +12223,7 @@ var JobRunner = class {
11527
12223
  answerText: normalized.answerText,
11528
12224
  citedDomains: JSON.stringify(normalized.citedDomains),
11529
12225
  competitorOverlap: JSON.stringify(overlap),
12226
+ recommendedCompetitors: JSON.stringify(extractedCompetitors),
11530
12227
  location: runLocation?.label ?? null,
11531
12228
  rawResponse: JSON.stringify({
11532
12229
  model: raw.model,
@@ -11750,10 +12447,101 @@ function computeCompetitorOverlap(normalized, competitorDomains) {
11750
12447
  }
11751
12448
  return [...overlapSet];
11752
12449
  }
12450
+ function extractRecommendedCompetitors(answerText, ownDomains, citedDomains, competitorDomains) {
12451
+ if (!answerText || answerText.length < 20) return [];
12452
+ const ownBrandKeys = new Set(
12453
+ ownDomains.flatMap((domain) => collectBrandKeysFromDomain(domain))
12454
+ );
12455
+ const knownCompetitorKeys = new Set(
12456
+ [...citedDomains, ...competitorDomains].flatMap((domain) => collectBrandKeysFromDomain(domain)).filter((key) => !ownBrandKeys.has(key))
12457
+ );
12458
+ if (knownCompetitorKeys.size === 0) return [];
12459
+ const candidatePatterns = [
12460
+ /^\s*(?:[-*]|\d+\.)\s+(?:\*\*)?([A-Z0-9][A-Za-z0-9][\w\s.&',/()-]{1,50}?)(?:\*\*)?\s*[:\u2014\u2013–-]/gm,
12461
+ /\*\*([A-Z0-9][A-Za-z0-9][\w\s.&',/()-]{1,50}?)\*\*/g,
12462
+ /^#{1,4}\s+(?:\d+\.\s+)?(?:\*\*)?([A-Z0-9][A-Za-z0-9][\w\s.&',/()-]{1,50}?)(?:\*\*)?$/gm,
12463
+ /\[([A-Z0-9][A-Za-z0-9][\w\s.&',/()-]{1,50}?)\]\(https?:\/\/[^\s)]+\)/g
12464
+ ];
12465
+ const genericKeys = /* @__PURE__ */ new Set([
12466
+ "additional",
12467
+ "best",
12468
+ "benefits",
12469
+ "bottomline",
12470
+ "comparison",
12471
+ "conclusion",
12472
+ "directorylisting",
12473
+ "example",
12474
+ "expertise",
12475
+ "features",
12476
+ "finalthoughts",
12477
+ "howitworks",
12478
+ "important",
12479
+ "keybenefits",
12480
+ "keyfeatures",
12481
+ "major",
12482
+ "note",
12483
+ "notable",
12484
+ "option",
12485
+ "other",
12486
+ "overview",
12487
+ "pricing",
12488
+ "pros",
12489
+ "reviews",
12490
+ "step",
12491
+ "summary",
12492
+ "top",
12493
+ "verdict",
12494
+ "whattolookfor",
12495
+ "whyitmatters",
12496
+ "whyitstandsout",
12497
+ "whywechoseit"
12498
+ ]);
12499
+ const seen = /* @__PURE__ */ new Map();
12500
+ for (const pattern of candidatePatterns) {
12501
+ let match;
12502
+ while ((match = pattern.exec(answerText)) !== null) {
12503
+ const candidate = cleanCandidateName(match[1] ?? "");
12504
+ const candidateKey = brandKeyFromText(candidate);
12505
+ if (!candidateKey) continue;
12506
+ if (genericKeys.has(candidateKey)) continue;
12507
+ if (candidate.split(/\s+/).length > 6) continue;
12508
+ if (matchesBrandKey(candidateKey, ownBrandKeys)) continue;
12509
+ if (!matchesBrandKey(candidateKey, knownCompetitorKeys)) continue;
12510
+ if (!seen.has(candidateKey)) seen.set(candidateKey, candidate);
12511
+ }
12512
+ }
12513
+ return [...seen.values()].slice(0, 10);
12514
+ }
12515
+ function cleanCandidateName(candidate) {
12516
+ return candidate.replace(/^[\s"'`]+|[\s"'`.,:;!?]+$/g, "").replace(/\s+/g, " ").trim();
12517
+ }
12518
+ function brandKeyFromText(value) {
12519
+ return value.toLowerCase().replace(/[^a-z0-9]/g, "");
12520
+ }
12521
+ function collectBrandKeysFromDomain(domain) {
12522
+ const hostname = normalizeProjectDomain(domain).split("/")[0] ?? "";
12523
+ const labels = hostname.split(".").filter(Boolean);
12524
+ const keys = /* @__PURE__ */ new Set();
12525
+ const hostnameKey = hostname.replace(/[^a-z0-9]/gi, "").toLowerCase();
12526
+ if (hostnameKey.length >= 4) keys.add(hostnameKey);
12527
+ for (const label of labels) {
12528
+ const key = label.replace(/[^a-z0-9]/gi, "").toLowerCase();
12529
+ if (key.length >= 4) keys.add(key);
12530
+ }
12531
+ return [...keys];
12532
+ }
12533
+ function matchesBrandKey(candidateKey, brandKeys) {
12534
+ for (const brandKey of brandKeys) {
12535
+ if (candidateKey === brandKey) return true;
12536
+ if (candidateKey.startsWith(brandKey) || candidateKey.endsWith(brandKey)) return true;
12537
+ if (brandKey.startsWith(candidateKey) || brandKey.endsWith(candidateKey)) return true;
12538
+ }
12539
+ return false;
12540
+ }
11753
12541
 
11754
12542
  // src/gsc-sync.ts
11755
12543
  import crypto19 from "crypto";
11756
- import { eq as eq18, and as and8, sql as sql4 } from "drizzle-orm";
12544
+ import { eq as eq18, and as and7, sql as sql5 } from "drizzle-orm";
11757
12545
  var log2 = createLogger("GscSync");
11758
12546
  function formatDate2(d) {
11759
12547
  return d.toISOString().split("T")[0];
@@ -11805,10 +12593,10 @@ async function executeGscSync(db, runId, projectId, opts) {
11805
12593
  });
11806
12594
  log2.info("fetch.complete", { runId, projectId, rowCount: rows.length });
11807
12595
  db.delete(gscSearchData).where(
11808
- and8(
12596
+ and7(
11809
12597
  eq18(gscSearchData.projectId, projectId),
11810
- sql4`${gscSearchData.date} >= ${startDate}`,
11811
- sql4`${gscSearchData.date} <= ${endDate}`
12598
+ sql5`${gscSearchData.date} >= ${startDate}`,
12599
+ sql5`${gscSearchData.date} <= ${endDate}`
11812
12600
  )
11813
12601
  ).run();
11814
12602
  const batchSize = 500;
@@ -11894,7 +12682,7 @@ async function executeGscSync(db, runId, projectId, opts) {
11894
12682
  }
11895
12683
  }
11896
12684
  const snapshotDate = formatDate2(/* @__PURE__ */ new Date());
11897
- db.delete(gscCoverageSnapshots).where(and8(eq18(gscCoverageSnapshots.projectId, projectId), eq18(gscCoverageSnapshots.date, snapshotDate))).run();
12685
+ db.delete(gscCoverageSnapshots).where(and7(eq18(gscCoverageSnapshots.projectId, projectId), eq18(gscCoverageSnapshots.date, snapshotDate))).run();
11898
12686
  db.insert(gscCoverageSnapshots).values({
11899
12687
  id: crypto19.randomUUID(),
11900
12688
  projectId,
@@ -11917,7 +12705,7 @@ async function executeGscSync(db, runId, projectId, opts) {
11917
12705
 
11918
12706
  // src/gsc-inspect-sitemap.ts
11919
12707
  import crypto20 from "crypto";
11920
- import { eq as eq19, and as and9 } from "drizzle-orm";
12708
+ import { eq as eq19, and as and8 } from "drizzle-orm";
11921
12709
 
11922
12710
  // src/sitemap-parser.ts
11923
12711
  var LOC_REGEX = /<loc>\s*([^<]+?)\s*<\/loc>/gi;
@@ -12081,7 +12869,7 @@ async function executeInspectSitemap(db, runId, projectId, opts) {
12081
12869
  }
12082
12870
  }
12083
12871
  const snapshotDate = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
12084
- db.delete(gscCoverageSnapshots).where(and9(eq19(gscCoverageSnapshots.projectId, projectId), eq19(gscCoverageSnapshots.date, snapshotDate))).run();
12872
+ db.delete(gscCoverageSnapshots).where(and8(eq19(gscCoverageSnapshots.projectId, projectId), eq19(gscCoverageSnapshots.date, snapshotDate))).run();
12085
12873
  db.insert(gscCoverageSnapshots).values({
12086
12874
  id: crypto20.randomUUID(),
12087
12875
  projectId,
@@ -12274,7 +13062,7 @@ var Scheduler = class {
12274
13062
  };
12275
13063
 
12276
13064
  // src/notifier.ts
12277
- import { eq as eq21, desc as desc7, and as and10, or as or2 } from "drizzle-orm";
13065
+ import { eq as eq21, desc as desc7, and as and9, or as or2 } from "drizzle-orm";
12278
13066
  import crypto21 from "crypto";
12279
13067
  var log5 = createLogger("Notifier");
12280
13068
  var Notifier = class {
@@ -12338,7 +13126,7 @@ var Notifier = class {
12338
13126
  }
12339
13127
  computeTransitions(runId, projectId) {
12340
13128
  const recentRuns = this.db.select().from(runs).where(
12341
- and10(
13129
+ and9(
12342
13130
  eq21(runs.projectId, projectId),
12343
13131
  or2(eq21(runs.status, "completed"), eq21(runs.status, "partial"))
12344
13132
  )
@@ -12825,7 +13613,7 @@ var SnapshotService = class {
12825
13613
  heuristicCompetitors: result.recommendedCompetitors,
12826
13614
  citedDomains: result.citedDomains,
12827
13615
  groundingSources: result.groundingSources.map((source) => source.uri),
12828
- answerText: clipText(result.answerText, 420)
13616
+ answerText: clipText(result.answerText, 2e3)
12829
13617
  }))
12830
13618
  );
12831
13619
  if (responses.length === 0) {
@@ -12853,7 +13641,7 @@ var SnapshotService = class {
12853
13641
  accuracyNotes: assessment.accuracyNotes ?? null,
12854
13642
  incorrectClaims: uniqueStrings(assessment.incorrectClaims ?? []).slice(0, 5),
12855
13643
  ...hasReviewedCompetitors ? {
12856
- recommendedCompetitors: uniqueStrings(assessment.recommendedCompetitors ?? []).slice(0, 6)
13644
+ recommendedCompetitors: uniqueStrings(assessment.recommendedCompetitors ?? []).slice(0, 10)
12857
13645
  } : {}
12858
13646
  };
12859
13647
  }),
@@ -12906,7 +13694,13 @@ function buildBatchAnalysisPrompt(ctx) {
12906
13694
  "Return strict JSON with keys: assessments, whatThisMeans, recommendedActions.",
12907
13695
  "Each assessment must include: phrase, provider, mentioned, describedAccurately, accuracyNotes, incorrectClaims, recommendedCompetitors.",
12908
13696
  "describedAccurately must be one of: yes, no, unknown, not-mentioned.",
12909
- "recommendedCompetitors should list actual competitor brands or domains named in the answer, not generic directories unless that is the only thing recommended.",
13697
+ "",
13698
+ "CRITICAL \u2014 recommendedCompetitors extraction:",
13699
+ "For each response, extract EVERY specific company/brand/product name that the AI recommended or listed as an alternative.",
13700
+ 'Include the company name exactly as it appears in the response (e.g. "Accenture", "Deloitte", "C3.ai").',
13701
+ 'Do NOT include generic terms like "consulting firms" or directories like "G2" or "Clutch".',
13702
+ "Do NOT include the target company itself.",
13703
+ "This is the most important field \u2014 it shows the prospect who AI recommends INSTEAD of them.",
12910
13704
  "",
12911
13705
  `Target company: ${ctx.companyName}`,
12912
13706
  `Target domain: ${ctx.domain}`,
@@ -13042,7 +13836,7 @@ function mentionsTargetCompany(answerText, companyName, domain) {
13042
13836
  if (targetTokens.length === 0) return false;
13043
13837
  let matches = 0;
13044
13838
  for (const token of targetTokens) {
13045
- if (new RegExp(`\\b${escapeRegExp(token)}\\b`, "i").test(answerText)) {
13839
+ if (new RegExp(`\\b${escapeRegExp2(token)}\\b`, "i").test(answerText)) {
13046
13840
  matches++;
13047
13841
  }
13048
13842
  }
@@ -13128,7 +13922,10 @@ function normalizeStringList(values) {
13128
13922
  );
13129
13923
  }
13130
13924
  function uniqueStrings(values) {
13131
- return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
13925
+ if (!Array.isArray(values)) return [];
13926
+ return [...new Set(
13927
+ values.filter((value) => typeof value === "string").map((value) => value.trim()).filter(Boolean)
13928
+ )];
13132
13929
  }
13133
13930
  function normalizeDomain(value) {
13134
13931
  const trimmed = value.trim();
@@ -13159,7 +13956,7 @@ function clipText(value, length) {
13159
13956
  if (value.length <= length) return value;
13160
13957
  return `${value.slice(0, length - 3)}...`;
13161
13958
  }
13162
- function escapeRegExp(value) {
13959
+ function escapeRegExp2(value) {
13163
13960
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
13164
13961
  }
13165
13962