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