@ainyc/canonry 1.8.0 → 1.10.1
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-BDoQOjXO.js +243 -0
- package/assets/assets/index-Dhvjw6Lo.css +1 -0
- package/assets/index.html +2 -2
- package/dist/{chunk-3FHF3YVA.js → chunk-2DC7RBXJ.js} +1025 -135
- package/dist/cli.js +427 -8
- package/dist/index.js +1 -1
- package/package.json +4 -3
- package/assets/assets/index-Co-wrY40.css +0 -1
- package/assets/assets/index-xvy_ShfV.js +0 -243
|
@@ -140,6 +140,7 @@ function trackEvent(event, properties) {
|
|
|
140
140
|
|
|
141
141
|
// src/server.ts
|
|
142
142
|
import { createRequire as createRequire2 } from "module";
|
|
143
|
+
import crypto16 from "crypto";
|
|
143
144
|
import fs2 from "fs";
|
|
144
145
|
import path2 from "path";
|
|
145
146
|
import { fileURLToPath } from "url";
|
|
@@ -161,6 +162,9 @@ __export(schema_exports, {
|
|
|
161
162
|
apiKeys: () => apiKeys,
|
|
162
163
|
auditLog: () => auditLog,
|
|
163
164
|
competitors: () => competitors,
|
|
165
|
+
googleConnections: () => googleConnections,
|
|
166
|
+
gscSearchData: () => gscSearchData,
|
|
167
|
+
gscUrlInspections: () => gscUrlInspections,
|
|
164
168
|
keywords: () => keywords,
|
|
165
169
|
notifications: () => notifications,
|
|
166
170
|
projects: () => projects,
|
|
@@ -223,6 +227,7 @@ var querySnapshots = sqliteTable("query_snapshots", {
|
|
|
223
227
|
runId: text("run_id").notNull().references(() => runs.id, { onDelete: "cascade" }),
|
|
224
228
|
keywordId: text("keyword_id").notNull().references(() => keywords.id, { onDelete: "cascade" }),
|
|
225
229
|
provider: text("provider").notNull().default("gemini"),
|
|
230
|
+
model: text("model"),
|
|
226
231
|
citationState: text("citation_state").notNull(),
|
|
227
232
|
answerText: text("answer_text"),
|
|
228
233
|
citedDomains: text("cited_domains").notNull().default("[]"),
|
|
@@ -285,6 +290,61 @@ var notifications = sqliteTable("notifications", {
|
|
|
285
290
|
}, (table) => [
|
|
286
291
|
index("idx_notifications_project").on(table.projectId)
|
|
287
292
|
]);
|
|
293
|
+
var googleConnections = sqliteTable("google_connections", {
|
|
294
|
+
id: text("id").primaryKey(),
|
|
295
|
+
domain: text("domain").notNull(),
|
|
296
|
+
connectionType: text("connection_type").notNull(),
|
|
297
|
+
propertyId: text("property_id"),
|
|
298
|
+
accessToken: text("access_token"),
|
|
299
|
+
refreshToken: text("refresh_token"),
|
|
300
|
+
tokenExpiresAt: text("token_expires_at"),
|
|
301
|
+
scopes: text("scopes").notNull().default("[]"),
|
|
302
|
+
createdAt: text("created_at").notNull(),
|
|
303
|
+
updatedAt: text("updated_at").notNull()
|
|
304
|
+
}, (table) => [
|
|
305
|
+
uniqueIndex("idx_google_conn_domain_type").on(table.domain, table.connectionType)
|
|
306
|
+
]);
|
|
307
|
+
var gscSearchData = sqliteTable("gsc_search_data", {
|
|
308
|
+
id: text("id").primaryKey(),
|
|
309
|
+
projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
|
310
|
+
syncRunId: text("sync_run_id").notNull().references(() => runs.id, { onDelete: "cascade" }),
|
|
311
|
+
date: text("date").notNull(),
|
|
312
|
+
query: text("query").notNull(),
|
|
313
|
+
page: text("page").notNull(),
|
|
314
|
+
country: text("country"),
|
|
315
|
+
device: text("device"),
|
|
316
|
+
clicks: integer("clicks").notNull().default(0),
|
|
317
|
+
impressions: integer("impressions").notNull().default(0),
|
|
318
|
+
ctr: text("ctr").notNull().default("0"),
|
|
319
|
+
position: text("position").notNull().default("0"),
|
|
320
|
+
createdAt: text("created_at").notNull()
|
|
321
|
+
}, (table) => [
|
|
322
|
+
index("idx_gsc_search_project_date").on(table.projectId, table.date),
|
|
323
|
+
index("idx_gsc_search_query").on(table.query),
|
|
324
|
+
index("idx_gsc_search_run").on(table.syncRunId)
|
|
325
|
+
]);
|
|
326
|
+
var gscUrlInspections = sqliteTable("gsc_url_inspections", {
|
|
327
|
+
id: text("id").primaryKey(),
|
|
328
|
+
projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
|
329
|
+
syncRunId: text("sync_run_id").references(() => runs.id, { onDelete: "cascade" }),
|
|
330
|
+
url: text("url").notNull(),
|
|
331
|
+
indexingState: text("indexing_state"),
|
|
332
|
+
verdict: text("verdict"),
|
|
333
|
+
coverageState: text("coverage_state"),
|
|
334
|
+
pageFetchState: text("page_fetch_state"),
|
|
335
|
+
robotsTxtState: text("robots_txt_state"),
|
|
336
|
+
crawlTime: text("crawl_time"),
|
|
337
|
+
lastCrawlResult: text("last_crawl_result"),
|
|
338
|
+
isMobileFriendly: integer("is_mobile_friendly"),
|
|
339
|
+
richResults: text("rich_results").notNull().default("[]"),
|
|
340
|
+
referringUrls: text("referring_urls").notNull().default("[]"),
|
|
341
|
+
inspectedAt: text("inspected_at").notNull(),
|
|
342
|
+
createdAt: text("created_at").notNull()
|
|
343
|
+
}, (table) => [
|
|
344
|
+
index("idx_gsc_inspect_project_url").on(table.projectId, table.url),
|
|
345
|
+
index("idx_gsc_inspect_run").on(table.syncRunId),
|
|
346
|
+
index("idx_gsc_inspect_url_time").on(table.url, table.inspectedAt)
|
|
347
|
+
]);
|
|
288
348
|
var usageCounters = sqliteTable("usage_counters", {
|
|
289
349
|
id: text("id").primaryKey(),
|
|
290
350
|
scope: text("scope").notNull(),
|
|
@@ -443,7 +503,66 @@ var MIGRATIONS = [
|
|
|
443
503
|
// v3: Add webhook_secret column to notifications for HMAC signing
|
|
444
504
|
`ALTER TABLE notifications ADD COLUMN webhook_secret TEXT`,
|
|
445
505
|
// v4: Add owned_domains column to projects for multi-domain citation matching
|
|
446
|
-
`ALTER TABLE projects ADD COLUMN owned_domains TEXT NOT NULL DEFAULT '[]'
|
|
506
|
+
`ALTER TABLE projects ADD COLUMN owned_domains TEXT NOT NULL DEFAULT '[]'`,
|
|
507
|
+
// v5: Add model column to query_snapshots for per-model scoring
|
|
508
|
+
`ALTER TABLE query_snapshots ADD COLUMN model TEXT`,
|
|
509
|
+
// v5b: Backfill model from rawResponse JSON for existing snapshots
|
|
510
|
+
`UPDATE query_snapshots SET model = json_extract(raw_response, '$.model') WHERE model IS NULL AND raw_response IS NOT NULL AND json_extract(raw_response, '$.model') IS NOT NULL`,
|
|
511
|
+
// v6: Google Search Console integration — google_connections table (domain-scoped)
|
|
512
|
+
`CREATE TABLE IF NOT EXISTS google_connections (
|
|
513
|
+
id TEXT PRIMARY KEY,
|
|
514
|
+
domain TEXT NOT NULL,
|
|
515
|
+
connection_type TEXT NOT NULL,
|
|
516
|
+
property_id TEXT,
|
|
517
|
+
access_token TEXT,
|
|
518
|
+
refresh_token TEXT,
|
|
519
|
+
token_expires_at TEXT,
|
|
520
|
+
scopes TEXT NOT NULL DEFAULT '[]',
|
|
521
|
+
created_at TEXT NOT NULL,
|
|
522
|
+
updated_at TEXT NOT NULL
|
|
523
|
+
)`,
|
|
524
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_google_conn_domain_type ON google_connections(domain, connection_type)`,
|
|
525
|
+
// v6: Google Search Console integration — gsc_search_data table
|
|
526
|
+
`CREATE TABLE IF NOT EXISTS gsc_search_data (
|
|
527
|
+
id TEXT PRIMARY KEY,
|
|
528
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
529
|
+
sync_run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
|
|
530
|
+
date TEXT NOT NULL,
|
|
531
|
+
query TEXT NOT NULL,
|
|
532
|
+
page TEXT NOT NULL,
|
|
533
|
+
country TEXT,
|
|
534
|
+
device TEXT,
|
|
535
|
+
clicks INTEGER NOT NULL DEFAULT 0,
|
|
536
|
+
impressions INTEGER NOT NULL DEFAULT 0,
|
|
537
|
+
ctr TEXT NOT NULL DEFAULT '0',
|
|
538
|
+
position TEXT NOT NULL DEFAULT '0',
|
|
539
|
+
created_at TEXT NOT NULL
|
|
540
|
+
)`,
|
|
541
|
+
`CREATE INDEX IF NOT EXISTS idx_gsc_search_project_date ON gsc_search_data(project_id, date)`,
|
|
542
|
+
`CREATE INDEX IF NOT EXISTS idx_gsc_search_query ON gsc_search_data(query)`,
|
|
543
|
+
`CREATE INDEX IF NOT EXISTS idx_gsc_search_run ON gsc_search_data(sync_run_id)`,
|
|
544
|
+
// v6: Google Search Console integration — gsc_url_inspections table
|
|
545
|
+
`CREATE TABLE IF NOT EXISTS gsc_url_inspections (
|
|
546
|
+
id TEXT PRIMARY KEY,
|
|
547
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
548
|
+
sync_run_id TEXT REFERENCES runs(id) ON DELETE CASCADE,
|
|
549
|
+
url TEXT NOT NULL,
|
|
550
|
+
indexing_state TEXT,
|
|
551
|
+
verdict TEXT,
|
|
552
|
+
coverage_state TEXT,
|
|
553
|
+
page_fetch_state TEXT,
|
|
554
|
+
robots_txt_state TEXT,
|
|
555
|
+
crawl_time TEXT,
|
|
556
|
+
last_crawl_result TEXT,
|
|
557
|
+
is_mobile_friendly INTEGER,
|
|
558
|
+
rich_results TEXT NOT NULL DEFAULT '[]',
|
|
559
|
+
referring_urls TEXT NOT NULL DEFAULT '[]',
|
|
560
|
+
inspected_at TEXT NOT NULL,
|
|
561
|
+
created_at TEXT NOT NULL
|
|
562
|
+
)`,
|
|
563
|
+
`CREATE INDEX IF NOT EXISTS idx_gsc_inspect_project_url ON gsc_url_inspections(project_id, url)`,
|
|
564
|
+
`CREATE INDEX IF NOT EXISTS idx_gsc_inspect_run ON gsc_url_inspections(sync_run_id)`,
|
|
565
|
+
`CREATE INDEX IF NOT EXISTS idx_gsc_inspect_url_time ON gsc_url_inspections(url, inspected_at)`
|
|
447
566
|
];
|
|
448
567
|
function migrate(db) {
|
|
449
568
|
const statements = MIGRATION_SQL.split(";").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
@@ -516,6 +635,15 @@ var configNotificationSchema = z3.object({
|
|
|
516
635
|
url: z3.string().url(),
|
|
517
636
|
events: z3.array(notificationEventSchema).min(1)
|
|
518
637
|
});
|
|
638
|
+
var configGoogleSchema = z3.object({
|
|
639
|
+
gsc: z3.object({
|
|
640
|
+
propertyUrl: z3.string()
|
|
641
|
+
}).optional(),
|
|
642
|
+
syncSchedule: z3.object({
|
|
643
|
+
preset: z3.string().optional(),
|
|
644
|
+
cron: z3.string().optional()
|
|
645
|
+
}).optional()
|
|
646
|
+
}).optional();
|
|
519
647
|
var configSpecSchema = z3.object({
|
|
520
648
|
displayName: z3.string().min(1),
|
|
521
649
|
canonicalDomain: z3.string().min(1),
|
|
@@ -526,7 +654,8 @@ var configSpecSchema = z3.object({
|
|
|
526
654
|
competitors: z3.array(z3.string().min(1)).optional().default([]),
|
|
527
655
|
providers: z3.array(providerNameSchema).optional().default([]),
|
|
528
656
|
schedule: configScheduleSchema,
|
|
529
|
-
notifications: z3.array(configNotificationSchema).optional().default([])
|
|
657
|
+
notifications: z3.array(configNotificationSchema).optional().default([]),
|
|
658
|
+
google: configGoogleSchema
|
|
530
659
|
});
|
|
531
660
|
var projectConfigSchema = z3.object({
|
|
532
661
|
apiVersion: z3.literal("canonry/v1"),
|
|
@@ -535,6 +664,58 @@ var projectConfigSchema = z3.object({
|
|
|
535
664
|
spec: configSpecSchema
|
|
536
665
|
});
|
|
537
666
|
|
|
667
|
+
// ../contracts/src/models.ts
|
|
668
|
+
var MODEL_REGISTRY = {
|
|
669
|
+
gemini: {
|
|
670
|
+
defaultModel: "gemini-3-flash",
|
|
671
|
+
validationPattern: /^gemini-/,
|
|
672
|
+
validationHint: 'model name must start with "gemini-" (e.g. gemini-3-flash)',
|
|
673
|
+
knownModels: [
|
|
674
|
+
{ id: "gemini-3.1-pro-preview", displayName: "Gemini 3.1 Pro (Preview)", tier: "flagship" },
|
|
675
|
+
{ id: "gemini-3-flash-preview", displayName: "Gemini 3 Flash (Preview)", tier: "standard" },
|
|
676
|
+
{ id: "gemini-3.1-flash-lite-preview", displayName: "Gemini 3.1 Flash-Lite (Preview)", tier: "economy" },
|
|
677
|
+
{ id: "gemini-2.5-flash", displayName: "Gemini 2.5 Flash", tier: "standard" }
|
|
678
|
+
]
|
|
679
|
+
},
|
|
680
|
+
openai: {
|
|
681
|
+
defaultModel: "gpt-5.4",
|
|
682
|
+
validationPattern: /^(gpt-|o\d)/,
|
|
683
|
+
validationHint: "expected a GPT or o-series model name (e.g. gpt-5.4, o3)",
|
|
684
|
+
knownModels: [
|
|
685
|
+
{ id: "gpt-5.4", displayName: "GPT-5.4", tier: "flagship" },
|
|
686
|
+
{ id: "gpt-5.4-pro", displayName: "GPT-5.4 Pro", tier: "flagship" },
|
|
687
|
+
{ id: "gpt-5-mini", displayName: "GPT-5 Mini", tier: "fast" },
|
|
688
|
+
{ id: "gpt-5-nano", displayName: "GPT-5 Nano", tier: "economy" },
|
|
689
|
+
{ id: "gpt-5", displayName: "GPT-5", tier: "standard" },
|
|
690
|
+
{ id: "gpt-4.1", displayName: "GPT-4.1", tier: "standard" }
|
|
691
|
+
]
|
|
692
|
+
},
|
|
693
|
+
claude: {
|
|
694
|
+
defaultModel: "claude-sonnet-4-6",
|
|
695
|
+
validationPattern: /^claude-/,
|
|
696
|
+
validationHint: 'model name must start with "claude-" (e.g. claude-sonnet-4-6)',
|
|
697
|
+
knownModels: [
|
|
698
|
+
{ id: "claude-opus-4-6", displayName: "Claude Opus 4.6", tier: "flagship" },
|
|
699
|
+
{ id: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6", tier: "standard" },
|
|
700
|
+
{ id: "claude-haiku-4-5", displayName: "Claude Haiku 4.5", tier: "fast" }
|
|
701
|
+
]
|
|
702
|
+
},
|
|
703
|
+
local: {
|
|
704
|
+
defaultModel: "llama3",
|
|
705
|
+
validationPattern: /./,
|
|
706
|
+
validationHint: "any model name accepted",
|
|
707
|
+
knownModels: [
|
|
708
|
+
{ id: "llama3", displayName: "Llama 3", tier: "standard" }
|
|
709
|
+
]
|
|
710
|
+
}
|
|
711
|
+
};
|
|
712
|
+
function getDefaultModel(provider) {
|
|
713
|
+
return MODEL_REGISTRY[provider].defaultModel;
|
|
714
|
+
}
|
|
715
|
+
function isValidModelName(provider, model) {
|
|
716
|
+
return MODEL_REGISTRY[provider].validationPattern.test(model);
|
|
717
|
+
}
|
|
718
|
+
|
|
538
719
|
// ../contracts/src/errors.ts
|
|
539
720
|
var AppError = class extends Error {
|
|
540
721
|
code;
|
|
@@ -576,23 +757,62 @@ function unsupportedKind(kind) {
|
|
|
576
757
|
return new AppError("UNSUPPORTED_KIND", `Kind '${kind}' is not supported in this version`, 400);
|
|
577
758
|
}
|
|
578
759
|
|
|
579
|
-
// ../contracts/src/
|
|
760
|
+
// ../contracts/src/google.ts
|
|
580
761
|
import { z as z4 } from "zod";
|
|
581
|
-
var
|
|
582
|
-
var
|
|
762
|
+
var googleConnectionTypeSchema = z4.enum(["gsc", "ga4"]);
|
|
763
|
+
var googleConnectionDtoSchema = z4.object({
|
|
583
764
|
id: z4.string(),
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
765
|
+
domain: z4.string(),
|
|
766
|
+
connectionType: googleConnectionTypeSchema,
|
|
767
|
+
propertyId: z4.string().nullable().optional(),
|
|
768
|
+
scopes: z4.array(z4.string()).default([]),
|
|
769
|
+
createdAt: z4.string(),
|
|
770
|
+
updatedAt: z4.string()
|
|
771
|
+
});
|
|
772
|
+
var gscSearchDataDtoSchema = z4.object({
|
|
773
|
+
date: z4.string(),
|
|
774
|
+
query: z4.string(),
|
|
775
|
+
page: z4.string(),
|
|
776
|
+
country: z4.string().nullable().optional(),
|
|
777
|
+
device: z4.string().nullable().optional(),
|
|
778
|
+
clicks: z4.number(),
|
|
779
|
+
impressions: z4.number(),
|
|
780
|
+
ctr: z4.number(),
|
|
781
|
+
position: z4.number()
|
|
782
|
+
});
|
|
783
|
+
var gscUrlInspectionDtoSchema = z4.object({
|
|
784
|
+
id: z4.string(),
|
|
785
|
+
url: z4.string(),
|
|
786
|
+
indexingState: z4.string().nullable().optional(),
|
|
787
|
+
verdict: z4.string().nullable().optional(),
|
|
788
|
+
coverageState: z4.string().nullable().optional(),
|
|
789
|
+
pageFetchState: z4.string().nullable().optional(),
|
|
790
|
+
robotsTxtState: z4.string().nullable().optional(),
|
|
791
|
+
crawlTime: z4.string().nullable().optional(),
|
|
792
|
+
lastCrawlResult: z4.string().nullable().optional(),
|
|
793
|
+
isMobileFriendly: z4.boolean().nullable().optional(),
|
|
794
|
+
richResults: z4.array(z4.string()).default([]),
|
|
795
|
+
inspectedAt: z4.string()
|
|
796
|
+
});
|
|
797
|
+
var indexTransitionSchema = z4.enum(["stable", "reindexed", "deindexed", "still-missing", "new"]);
|
|
798
|
+
|
|
799
|
+
// ../contracts/src/project.ts
|
|
800
|
+
import { z as z5 } from "zod";
|
|
801
|
+
var configSourceSchema = z5.enum(["cli", "api", "config-file"]);
|
|
802
|
+
var projectDtoSchema = z5.object({
|
|
803
|
+
id: z5.string(),
|
|
804
|
+
name: z5.string(),
|
|
805
|
+
displayName: z5.string().optional(),
|
|
806
|
+
canonicalDomain: z5.string(),
|
|
807
|
+
ownedDomains: z5.array(z5.string()).default([]),
|
|
808
|
+
country: z5.string().length(2),
|
|
809
|
+
language: z5.string().min(2),
|
|
810
|
+
tags: z5.array(z5.string()).default([]),
|
|
811
|
+
labels: z5.record(z5.string(), z5.string()).default({}),
|
|
592
812
|
configSource: configSourceSchema.default("cli"),
|
|
593
|
-
configRevision:
|
|
594
|
-
createdAt:
|
|
595
|
-
updatedAt:
|
|
813
|
+
configRevision: z5.number().int().positive().default(1),
|
|
814
|
+
createdAt: z5.string().optional(),
|
|
815
|
+
updatedAt: z5.string().optional()
|
|
596
816
|
});
|
|
597
817
|
function normalizeProjectDomain(input) {
|
|
598
818
|
let domain = input.trim().toLowerCase();
|
|
@@ -620,68 +840,68 @@ function effectiveDomains(project) {
|
|
|
620
840
|
}
|
|
621
841
|
|
|
622
842
|
// ../contracts/src/run.ts
|
|
623
|
-
import { z as
|
|
624
|
-
var runStatusSchema =
|
|
625
|
-
var runKindSchema =
|
|
626
|
-
var runTriggerSchema =
|
|
627
|
-
var citationStateSchema =
|
|
628
|
-
var computedTransitionSchema =
|
|
629
|
-
var runDtoSchema =
|
|
630
|
-
id:
|
|
631
|
-
projectId:
|
|
843
|
+
import { z as z6 } from "zod";
|
|
844
|
+
var runStatusSchema = z6.enum(["queued", "running", "completed", "partial", "failed"]);
|
|
845
|
+
var runKindSchema = z6.enum(["answer-visibility", "site-audit", "gsc-sync"]);
|
|
846
|
+
var runTriggerSchema = z6.enum(["manual", "scheduled", "config-apply"]);
|
|
847
|
+
var citationStateSchema = z6.enum(["cited", "not-cited"]);
|
|
848
|
+
var computedTransitionSchema = z6.enum(["new", "cited", "lost", "emerging", "not-cited"]);
|
|
849
|
+
var runDtoSchema = z6.object({
|
|
850
|
+
id: z6.string(),
|
|
851
|
+
projectId: z6.string(),
|
|
632
852
|
kind: runKindSchema,
|
|
633
853
|
status: runStatusSchema,
|
|
634
854
|
trigger: runTriggerSchema.default("manual"),
|
|
635
|
-
startedAt:
|
|
636
|
-
finishedAt:
|
|
637
|
-
error:
|
|
638
|
-
createdAt:
|
|
855
|
+
startedAt: z6.string().nullable().optional(),
|
|
856
|
+
finishedAt: z6.string().nullable().optional(),
|
|
857
|
+
error: z6.string().nullable().optional(),
|
|
858
|
+
createdAt: z6.string()
|
|
639
859
|
});
|
|
640
|
-
var groundingSourceSchema =
|
|
641
|
-
uri:
|
|
642
|
-
title:
|
|
860
|
+
var groundingSourceSchema = z6.object({
|
|
861
|
+
uri: z6.string(),
|
|
862
|
+
title: z6.string()
|
|
643
863
|
});
|
|
644
|
-
var querySnapshotDtoSchema =
|
|
645
|
-
id:
|
|
646
|
-
runId:
|
|
647
|
-
keywordId:
|
|
648
|
-
keyword:
|
|
864
|
+
var querySnapshotDtoSchema = z6.object({
|
|
865
|
+
id: z6.string(),
|
|
866
|
+
runId: z6.string(),
|
|
867
|
+
keywordId: z6.string(),
|
|
868
|
+
keyword: z6.string().optional(),
|
|
649
869
|
provider: providerNameSchema,
|
|
650
870
|
citationState: citationStateSchema,
|
|
651
871
|
transition: computedTransitionSchema.optional(),
|
|
652
|
-
answerText:
|
|
653
|
-
citedDomains:
|
|
654
|
-
competitorOverlap:
|
|
655
|
-
groundingSources:
|
|
656
|
-
searchQueries:
|
|
657
|
-
model:
|
|
658
|
-
createdAt:
|
|
872
|
+
answerText: z6.string().nullable().optional(),
|
|
873
|
+
citedDomains: z6.array(z6.string()).default([]),
|
|
874
|
+
competitorOverlap: z6.array(z6.string()).default([]),
|
|
875
|
+
groundingSources: z6.array(groundingSourceSchema).default([]),
|
|
876
|
+
searchQueries: z6.array(z6.string()).default([]),
|
|
877
|
+
model: z6.string().nullable().optional(),
|
|
878
|
+
createdAt: z6.string()
|
|
659
879
|
});
|
|
660
|
-
var auditLogEntrySchema =
|
|
661
|
-
id:
|
|
662
|
-
projectId:
|
|
663
|
-
actor:
|
|
664
|
-
action:
|
|
665
|
-
entityType:
|
|
666
|
-
entityId:
|
|
667
|
-
diff:
|
|
668
|
-
createdAt:
|
|
880
|
+
var auditLogEntrySchema = z6.object({
|
|
881
|
+
id: z6.string(),
|
|
882
|
+
projectId: z6.string().nullable().optional(),
|
|
883
|
+
actor: z6.string(),
|
|
884
|
+
action: z6.string(),
|
|
885
|
+
entityType: z6.string(),
|
|
886
|
+
entityId: z6.string().nullable().optional(),
|
|
887
|
+
diff: z6.unknown().optional(),
|
|
888
|
+
createdAt: z6.string()
|
|
669
889
|
});
|
|
670
890
|
|
|
671
891
|
// ../contracts/src/schedule.ts
|
|
672
|
-
import { z as
|
|
673
|
-
var scheduleDtoSchema =
|
|
674
|
-
id:
|
|
675
|
-
projectId:
|
|
676
|
-
cronExpr:
|
|
677
|
-
preset:
|
|
678
|
-
timezone:
|
|
679
|
-
enabled:
|
|
680
|
-
providers:
|
|
681
|
-
lastRunAt:
|
|
682
|
-
nextRunAt:
|
|
683
|
-
createdAt:
|
|
684
|
-
updatedAt:
|
|
892
|
+
import { z as z7 } from "zod";
|
|
893
|
+
var scheduleDtoSchema = z7.object({
|
|
894
|
+
id: z7.string(),
|
|
895
|
+
projectId: z7.string(),
|
|
896
|
+
cronExpr: z7.string(),
|
|
897
|
+
preset: z7.string().nullable().optional(),
|
|
898
|
+
timezone: z7.string().default("UTC"),
|
|
899
|
+
enabled: z7.boolean().default(true),
|
|
900
|
+
providers: z7.array(providerNameSchema).default([]),
|
|
901
|
+
lastRunAt: z7.string().nullable().optional(),
|
|
902
|
+
nextRunAt: z7.string().nullable().optional(),
|
|
903
|
+
createdAt: z7.string(),
|
|
904
|
+
updatedAt: z7.string()
|
|
685
905
|
});
|
|
686
906
|
|
|
687
907
|
// ../api-routes/src/auth.ts
|
|
@@ -692,6 +912,7 @@ var SKIP_PATHS = ["/health"];
|
|
|
692
912
|
function shouldSkipAuth(url) {
|
|
693
913
|
if (SKIP_PATHS.includes(url)) return true;
|
|
694
914
|
if (url.endsWith("/openapi.json")) return true;
|
|
915
|
+
if (url.includes("/google/callback")) return true;
|
|
695
916
|
return false;
|
|
696
917
|
}
|
|
697
918
|
async function authPlugin(app) {
|
|
@@ -1253,6 +1474,7 @@ async function runRoutes(app, opts) {
|
|
|
1253
1474
|
keywordId: querySnapshots.keywordId,
|
|
1254
1475
|
keyword: keywords.keyword,
|
|
1255
1476
|
provider: querySnapshots.provider,
|
|
1477
|
+
model: querySnapshots.model,
|
|
1256
1478
|
citationState: querySnapshots.citationState,
|
|
1257
1479
|
answerText: querySnapshots.answerText,
|
|
1258
1480
|
citedDomains: querySnapshots.citedDomains,
|
|
@@ -1262,19 +1484,24 @@ async function runRoutes(app, opts) {
|
|
|
1262
1484
|
}).from(querySnapshots).leftJoin(keywords, eq7(querySnapshots.keywordId, keywords.id)).where(eq7(querySnapshots.runId, run.id)).all();
|
|
1263
1485
|
return reply.send({
|
|
1264
1486
|
...formatRun(run),
|
|
1265
|
-
snapshots: snapshots.map((s) =>
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1487
|
+
snapshots: snapshots.map((s) => {
|
|
1488
|
+
const rawParsed = parseSnapshotRawResponse(s.rawResponse);
|
|
1489
|
+
return {
|
|
1490
|
+
id: s.id,
|
|
1491
|
+
runId: s.runId,
|
|
1492
|
+
keywordId: s.keywordId,
|
|
1493
|
+
keyword: s.keyword,
|
|
1494
|
+
provider: s.provider,
|
|
1495
|
+
citationState: s.citationState,
|
|
1496
|
+
answerText: s.answerText,
|
|
1497
|
+
citedDomains: tryParseJson(s.citedDomains, []),
|
|
1498
|
+
competitorOverlap: tryParseJson(s.competitorOverlap, []),
|
|
1499
|
+
model: s.model ?? rawParsed.model,
|
|
1500
|
+
groundingSources: rawParsed.groundingSources,
|
|
1501
|
+
searchQueries: rawParsed.searchQueries,
|
|
1502
|
+
createdAt: s.createdAt
|
|
1503
|
+
};
|
|
1504
|
+
})
|
|
1278
1505
|
});
|
|
1279
1506
|
});
|
|
1280
1507
|
}
|
|
@@ -1321,7 +1548,7 @@ function resolveProjectSafe3(app, name, reply) {
|
|
|
1321
1548
|
|
|
1322
1549
|
// ../api-routes/src/apply.ts
|
|
1323
1550
|
import crypto9 from "crypto";
|
|
1324
|
-
import { eq as eq8 } from "drizzle-orm";
|
|
1551
|
+
import { eq as eq8, and as and3 } from "drizzle-orm";
|
|
1325
1552
|
|
|
1326
1553
|
// ../api-routes/src/schedule-utils.ts
|
|
1327
1554
|
var DAY_MAP = {
|
|
@@ -1758,6 +1985,13 @@ async function applyRoutes(app, opts) {
|
|
|
1758
1985
|
diff: { notifications: config.spec.notifications }
|
|
1759
1986
|
});
|
|
1760
1987
|
}
|
|
1988
|
+
if ("google" in rawSpec && config.spec.google?.gsc?.propertyUrl) {
|
|
1989
|
+
const domain = config.spec.canonicalDomain;
|
|
1990
|
+
const conn = app.db.select().from(googleConnections).where(and3(eq8(googleConnections.domain, domain), eq8(googleConnections.connectionType, "gsc"))).get();
|
|
1991
|
+
if (conn) {
|
|
1992
|
+
app.db.update(googleConnections).set({ propertyId: config.spec.google.gsc.propertyUrl, updatedAt: now }).where(eq8(googleConnections.id, conn.id)).run();
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1761
1995
|
const project = app.db.select().from(projects).where(eq8(projects.id, projectId)).get();
|
|
1762
1996
|
return reply.status(200).send({
|
|
1763
1997
|
id: project.id,
|
|
@@ -1806,6 +2040,7 @@ async function historyRoutes(app) {
|
|
|
1806
2040
|
keywordId: querySnapshots.keywordId,
|
|
1807
2041
|
keyword: keywords.keyword,
|
|
1808
2042
|
provider: querySnapshots.provider,
|
|
2043
|
+
model: querySnapshots.model,
|
|
1809
2044
|
citationState: querySnapshots.citationState,
|
|
1810
2045
|
answerText: querySnapshots.answerText,
|
|
1811
2046
|
citedDomains: querySnapshots.citedDomains,
|
|
@@ -1821,6 +2056,7 @@ async function historyRoutes(app) {
|
|
|
1821
2056
|
keywordId: s.keywordId,
|
|
1822
2057
|
keyword: s.keyword,
|
|
1823
2058
|
provider: s.provider,
|
|
2059
|
+
model: s.model,
|
|
1824
2060
|
citationState: s.citationState,
|
|
1825
2061
|
answerText: s.answerText,
|
|
1826
2062
|
citedDomains: tryParseJson2(s.citedDomains, []),
|
|
@@ -1856,6 +2092,13 @@ async function historyRoutes(app) {
|
|
|
1856
2092
|
if (arr) arr.push(snap);
|
|
1857
2093
|
else rawByKwProvider.set(key, [snap]);
|
|
1858
2094
|
}
|
|
2095
|
+
const rawByKwModel = /* @__PURE__ */ new Map();
|
|
2096
|
+
for (const snap of allSnapshots) {
|
|
2097
|
+
const key = `${snap.keywordId}::${snap.provider}:${snap.model ?? "unknown"}`;
|
|
2098
|
+
const arr = rawByKwModel.get(key);
|
|
2099
|
+
if (arr) arr.push(snap);
|
|
2100
|
+
else rawByKwModel.set(key, [snap]);
|
|
2101
|
+
}
|
|
1859
2102
|
function computeTransitions(snaps) {
|
|
1860
2103
|
return snaps.map((snap, idx) => {
|
|
1861
2104
|
const run = projectRuns.find((r) => r.id === snap.runId);
|
|
@@ -1888,10 +2131,18 @@ async function historyRoutes(app) {
|
|
|
1888
2131
|
const provSnaps = rawByKwProvider.get(pk).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
1889
2132
|
providerRuns[provider] = computeTransitions(provSnaps);
|
|
1890
2133
|
}
|
|
2134
|
+
const modelRuns = {};
|
|
2135
|
+
const modelKeys = [...rawByKwModel.keys()].filter((k) => k.startsWith(`${kw.id}::`));
|
|
2136
|
+
for (const mk of modelKeys) {
|
|
2137
|
+
const modelKey = mk.split("::")[1];
|
|
2138
|
+
const modelSnaps = rawByKwModel.get(mk).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
2139
|
+
modelRuns[modelKey] = computeTransitions(modelSnaps);
|
|
2140
|
+
}
|
|
1891
2141
|
return {
|
|
1892
2142
|
keyword: kw.keyword,
|
|
1893
2143
|
runs: runEntries,
|
|
1894
|
-
providerRuns
|
|
2144
|
+
providerRuns,
|
|
2145
|
+
modelRuns
|
|
1895
2146
|
};
|
|
1896
2147
|
});
|
|
1897
2148
|
return reply.send(timeline);
|
|
@@ -2655,19 +2906,10 @@ async function settingsRoutes(app, opts) {
|
|
|
2655
2906
|
}
|
|
2656
2907
|
}
|
|
2657
2908
|
if (model !== void 0) {
|
|
2658
|
-
|
|
2909
|
+
const registry = MODEL_REGISTRY[name];
|
|
2910
|
+
if (!registry.validationPattern.test(model)) {
|
|
2659
2911
|
return reply.status(400).send({
|
|
2660
|
-
error: { code: "VALIDATION_ERROR", message: `Invalid model "${model}" for provider "
|
|
2661
|
-
});
|
|
2662
|
-
}
|
|
2663
|
-
if (name === "openai" && !/^(gpt-|o\d)/.test(model)) {
|
|
2664
|
-
return reply.status(400).send({
|
|
2665
|
-
error: { code: "VALIDATION_ERROR", message: `Invalid model "${model}" for provider "openai" \u2014 expected a GPT or o-series model name (e.g. gpt-4o, o3)` }
|
|
2666
|
-
});
|
|
2667
|
-
}
|
|
2668
|
-
if (name === "claude" && !model.startsWith("claude-")) {
|
|
2669
|
-
return reply.status(400).send({
|
|
2670
|
-
error: { code: "VALIDATION_ERROR", message: `Invalid model "${model}" for provider "claude" \u2014 model name must start with "claude-" (e.g. claude-sonnet-4-6)` }
|
|
2912
|
+
error: { code: "VALIDATION_ERROR", message: `Invalid model "${model}" for provider "${name}" \u2014 ${registry.validationHint}` }
|
|
2671
2913
|
});
|
|
2672
2914
|
}
|
|
2673
2915
|
}
|
|
@@ -3004,6 +3246,501 @@ function resolveProjectSafe6(app, name, reply) {
|
|
|
3004
3246
|
}
|
|
3005
3247
|
}
|
|
3006
3248
|
|
|
3249
|
+
// ../api-routes/src/google.ts
|
|
3250
|
+
import crypto12 from "crypto";
|
|
3251
|
+
import { eq as eq12, and as and4, desc as desc2, sql as sql2 } from "drizzle-orm";
|
|
3252
|
+
|
|
3253
|
+
// ../integration-google/src/constants.ts
|
|
3254
|
+
var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
3255
|
+
var GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
3256
|
+
var GSC_SCOPE = "https://www.googleapis.com/auth/webmasters.readonly";
|
|
3257
|
+
var GSC_API_BASE = "https://www.googleapis.com/webmasters/v3";
|
|
3258
|
+
var URL_INSPECTION_API = "https://searchconsole.googleapis.com/v1/urlInspection/index:inspect";
|
|
3259
|
+
var GSC_MAX_ROWS_PER_REQUEST = 25e3;
|
|
3260
|
+
var GSC_DATA_LAG_DAYS = 3;
|
|
3261
|
+
|
|
3262
|
+
// ../integration-google/src/types.ts
|
|
3263
|
+
var GoogleAuthError = class extends Error {
|
|
3264
|
+
constructor(message) {
|
|
3265
|
+
super(message);
|
|
3266
|
+
this.name = "GoogleAuthError";
|
|
3267
|
+
}
|
|
3268
|
+
};
|
|
3269
|
+
var GoogleApiError = class extends Error {
|
|
3270
|
+
status;
|
|
3271
|
+
constructor(message, status) {
|
|
3272
|
+
super(message);
|
|
3273
|
+
this.name = "GoogleApiError";
|
|
3274
|
+
this.status = status;
|
|
3275
|
+
}
|
|
3276
|
+
};
|
|
3277
|
+
|
|
3278
|
+
// ../integration-google/src/oauth.ts
|
|
3279
|
+
function getAuthUrl(clientId, redirectUri, scopes, state) {
|
|
3280
|
+
const params = new URLSearchParams({
|
|
3281
|
+
client_id: clientId,
|
|
3282
|
+
redirect_uri: redirectUri,
|
|
3283
|
+
response_type: "code",
|
|
3284
|
+
scope: scopes.join(" "),
|
|
3285
|
+
access_type: "offline",
|
|
3286
|
+
prompt: "consent"
|
|
3287
|
+
});
|
|
3288
|
+
if (state) params.set("state", state);
|
|
3289
|
+
return `${GOOGLE_AUTH_URL}?${params.toString()}`;
|
|
3290
|
+
}
|
|
3291
|
+
async function exchangeCode(clientId, clientSecret, code, redirectUri) {
|
|
3292
|
+
const res = await fetch(GOOGLE_TOKEN_URL, {
|
|
3293
|
+
method: "POST",
|
|
3294
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
3295
|
+
body: new URLSearchParams({
|
|
3296
|
+
client_id: clientId,
|
|
3297
|
+
client_secret: clientSecret,
|
|
3298
|
+
code,
|
|
3299
|
+
redirect_uri: redirectUri,
|
|
3300
|
+
grant_type: "authorization_code"
|
|
3301
|
+
})
|
|
3302
|
+
});
|
|
3303
|
+
if (!res.ok) {
|
|
3304
|
+
const body = await res.text();
|
|
3305
|
+
throw new GoogleAuthError(`Token exchange failed (${res.status}): ${body}`);
|
|
3306
|
+
}
|
|
3307
|
+
return await res.json();
|
|
3308
|
+
}
|
|
3309
|
+
async function refreshAccessToken(clientId, clientSecret, currentRefreshToken) {
|
|
3310
|
+
const res = await fetch(GOOGLE_TOKEN_URL, {
|
|
3311
|
+
method: "POST",
|
|
3312
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
3313
|
+
body: new URLSearchParams({
|
|
3314
|
+
client_id: clientId,
|
|
3315
|
+
client_secret: clientSecret,
|
|
3316
|
+
refresh_token: currentRefreshToken,
|
|
3317
|
+
grant_type: "refresh_token"
|
|
3318
|
+
})
|
|
3319
|
+
});
|
|
3320
|
+
if (!res.ok) {
|
|
3321
|
+
const body = await res.text();
|
|
3322
|
+
throw new GoogleAuthError(`Token refresh failed (${res.status}): ${body}`);
|
|
3323
|
+
}
|
|
3324
|
+
return await res.json();
|
|
3325
|
+
}
|
|
3326
|
+
|
|
3327
|
+
// ../integration-google/src/gsc-client.ts
|
|
3328
|
+
async function gscFetch(accessToken, url, opts) {
|
|
3329
|
+
const method = opts?.method ?? "GET";
|
|
3330
|
+
const headers = {
|
|
3331
|
+
Authorization: `Bearer ${accessToken}`,
|
|
3332
|
+
"Content-Type": "application/json"
|
|
3333
|
+
};
|
|
3334
|
+
const res = await fetch(url, {
|
|
3335
|
+
method,
|
|
3336
|
+
headers,
|
|
3337
|
+
body: opts?.body != null ? JSON.stringify(opts.body) : void 0
|
|
3338
|
+
});
|
|
3339
|
+
if (res.status === 401) {
|
|
3340
|
+
throw new GoogleApiError("Access token expired or revoked", 401);
|
|
3341
|
+
}
|
|
3342
|
+
if (res.status === 429) {
|
|
3343
|
+
throw new GoogleApiError("Google API rate limit exceeded", 429);
|
|
3344
|
+
}
|
|
3345
|
+
if (!res.ok) {
|
|
3346
|
+
const body = await res.text();
|
|
3347
|
+
throw new GoogleApiError(`GSC API error (${res.status}): ${body}`, res.status);
|
|
3348
|
+
}
|
|
3349
|
+
return await res.json();
|
|
3350
|
+
}
|
|
3351
|
+
async function listSites(accessToken) {
|
|
3352
|
+
const data = await gscFetch(
|
|
3353
|
+
accessToken,
|
|
3354
|
+
`${GSC_API_BASE}/sites`
|
|
3355
|
+
);
|
|
3356
|
+
return data.siteEntry ?? [];
|
|
3357
|
+
}
|
|
3358
|
+
async function fetchSearchAnalytics(accessToken, siteUrl, opts) {
|
|
3359
|
+
const allRows = [];
|
|
3360
|
+
let startRow = 0;
|
|
3361
|
+
const dimensions = opts.dimensions ?? ["query", "page", "country", "device", "date"];
|
|
3362
|
+
for (; ; ) {
|
|
3363
|
+
const requestBody = {
|
|
3364
|
+
startDate: opts.startDate,
|
|
3365
|
+
endDate: opts.endDate,
|
|
3366
|
+
dimensions,
|
|
3367
|
+
rowLimit: GSC_MAX_ROWS_PER_REQUEST,
|
|
3368
|
+
startRow
|
|
3369
|
+
};
|
|
3370
|
+
if (opts.query || opts.page) {
|
|
3371
|
+
const filters = [];
|
|
3372
|
+
const filterList = [];
|
|
3373
|
+
if (opts.query) filterList.push({ dimension: "query", operator: "contains", expression: opts.query });
|
|
3374
|
+
if (opts.page) filterList.push({ dimension: "page", operator: "contains", expression: opts.page });
|
|
3375
|
+
filters.push({ filters: filterList });
|
|
3376
|
+
requestBody.dimensionFilterGroups = filters;
|
|
3377
|
+
}
|
|
3378
|
+
const encodedSiteUrl = encodeURIComponent(siteUrl);
|
|
3379
|
+
const data = await gscFetch(
|
|
3380
|
+
accessToken,
|
|
3381
|
+
`${GSC_API_BASE}/sites/${encodedSiteUrl}/searchAnalytics/query`,
|
|
3382
|
+
{ method: "POST", body: requestBody }
|
|
3383
|
+
);
|
|
3384
|
+
const rows = data.rows ?? [];
|
|
3385
|
+
allRows.push(...rows);
|
|
3386
|
+
if (rows.length < GSC_MAX_ROWS_PER_REQUEST) {
|
|
3387
|
+
break;
|
|
3388
|
+
}
|
|
3389
|
+
startRow += rows.length;
|
|
3390
|
+
}
|
|
3391
|
+
return allRows;
|
|
3392
|
+
}
|
|
3393
|
+
async function inspectUrl(accessToken, inspectionUrl, siteUrl) {
|
|
3394
|
+
return gscFetch(
|
|
3395
|
+
accessToken,
|
|
3396
|
+
URL_INSPECTION_API,
|
|
3397
|
+
{
|
|
3398
|
+
method: "POST",
|
|
3399
|
+
body: {
|
|
3400
|
+
inspectionUrl,
|
|
3401
|
+
siteUrl
|
|
3402
|
+
}
|
|
3403
|
+
}
|
|
3404
|
+
);
|
|
3405
|
+
}
|
|
3406
|
+
|
|
3407
|
+
// ../api-routes/src/google.ts
|
|
3408
|
+
function signState(payload, secret) {
|
|
3409
|
+
return crypto12.createHmac("sha256", secret).update(payload).digest("hex");
|
|
3410
|
+
}
|
|
3411
|
+
function buildSignedState(data, secret) {
|
|
3412
|
+
const payload = JSON.stringify(data);
|
|
3413
|
+
const sig = signState(payload, secret);
|
|
3414
|
+
return Buffer.from(JSON.stringify({ payload, sig })).toString("base64url");
|
|
3415
|
+
}
|
|
3416
|
+
function verifySignedState(encoded, secret) {
|
|
3417
|
+
try {
|
|
3418
|
+
const { payload, sig } = JSON.parse(Buffer.from(encoded, "base64url").toString());
|
|
3419
|
+
const expected = signState(payload, secret);
|
|
3420
|
+
if (!crypto12.timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex"))) return null;
|
|
3421
|
+
return JSON.parse(payload);
|
|
3422
|
+
} catch {
|
|
3423
|
+
return null;
|
|
3424
|
+
}
|
|
3425
|
+
}
|
|
3426
|
+
async function getValidToken(app, domain, connectionType, clientId, clientSecret) {
|
|
3427
|
+
const conn = app.db.select().from(googleConnections).where(and4(eq12(googleConnections.domain, domain), eq12(googleConnections.connectionType, connectionType))).get();
|
|
3428
|
+
if (!conn) {
|
|
3429
|
+
throw notFound("Google connection", connectionType);
|
|
3430
|
+
}
|
|
3431
|
+
if (!conn.accessToken || !conn.refreshToken) {
|
|
3432
|
+
throw validationError("Google connection is incomplete \u2014 please reconnect");
|
|
3433
|
+
}
|
|
3434
|
+
const expiresAt = conn.tokenExpiresAt ? new Date(conn.tokenExpiresAt).getTime() : 0;
|
|
3435
|
+
const fiveMinutes = 5 * 60 * 1e3;
|
|
3436
|
+
if (Date.now() > expiresAt - fiveMinutes) {
|
|
3437
|
+
const tokens = await refreshAccessToken(clientId, clientSecret, conn.refreshToken);
|
|
3438
|
+
const newExpiresAt = new Date(Date.now() + tokens.expires_in * 1e3).toISOString();
|
|
3439
|
+
app.db.update(googleConnections).set({
|
|
3440
|
+
accessToken: tokens.access_token,
|
|
3441
|
+
tokenExpiresAt: newExpiresAt,
|
|
3442
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3443
|
+
}).where(eq12(googleConnections.id, conn.id)).run();
|
|
3444
|
+
return { accessToken: tokens.access_token, connectionId: conn.id, propertyId: conn.propertyId };
|
|
3445
|
+
}
|
|
3446
|
+
return { accessToken: conn.accessToken, connectionId: conn.id, propertyId: conn.propertyId };
|
|
3447
|
+
}
|
|
3448
|
+
async function googleRoutes(app, opts) {
|
|
3449
|
+
const { googleClientId, googleClientSecret } = opts;
|
|
3450
|
+
const stateSecret = opts.googleStateSecret ?? "insecure-default-secret";
|
|
3451
|
+
app.get("/projects/:name/google/connections", async (request) => {
|
|
3452
|
+
const project = resolveProject(app.db, request.params.name);
|
|
3453
|
+
const conns = app.db.select({
|
|
3454
|
+
id: googleConnections.id,
|
|
3455
|
+
domain: googleConnections.domain,
|
|
3456
|
+
connectionType: googleConnections.connectionType,
|
|
3457
|
+
propertyId: googleConnections.propertyId,
|
|
3458
|
+
scopes: googleConnections.scopes,
|
|
3459
|
+
createdAt: googleConnections.createdAt,
|
|
3460
|
+
updatedAt: googleConnections.updatedAt
|
|
3461
|
+
}).from(googleConnections).where(eq12(googleConnections.domain, project.canonicalDomain)).all();
|
|
3462
|
+
return conns.map((c) => ({
|
|
3463
|
+
...c,
|
|
3464
|
+
scopes: JSON.parse(c.scopes)
|
|
3465
|
+
}));
|
|
3466
|
+
});
|
|
3467
|
+
app.post("/projects/:name/google/connect", async (request, reply) => {
|
|
3468
|
+
if (!googleClientId || !googleClientSecret) {
|
|
3469
|
+
const err = validationError("Google OAuth is not configured. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET environment variables.");
|
|
3470
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
3471
|
+
}
|
|
3472
|
+
const { type, propertyId } = request.body ?? {};
|
|
3473
|
+
if (!type || type !== "gsc" && type !== "ga4") {
|
|
3474
|
+
const err = validationError('type must be "gsc" or "ga4"');
|
|
3475
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
3476
|
+
}
|
|
3477
|
+
const project = resolveProject(app.db, request.params.name);
|
|
3478
|
+
const proto = request.headers["x-forwarded-proto"] ?? "http";
|
|
3479
|
+
const host = request.headers.host ?? "localhost:4100";
|
|
3480
|
+
const redirectUri = `${proto}://${host}/api/v1/projects/${encodeURIComponent(request.params.name)}/google/callback`;
|
|
3481
|
+
const scopes = type === "gsc" ? [GSC_SCOPE] : [];
|
|
3482
|
+
const stateEncoded = buildSignedState(
|
|
3483
|
+
{ domain: project.canonicalDomain, type, propertyId, redirectUri },
|
|
3484
|
+
stateSecret
|
|
3485
|
+
);
|
|
3486
|
+
const authUrl = getAuthUrl(googleClientId, redirectUri, scopes, stateEncoded);
|
|
3487
|
+
return { authUrl };
|
|
3488
|
+
});
|
|
3489
|
+
app.get("/projects/:name/google/callback", async (request, reply) => {
|
|
3490
|
+
if (!googleClientId || !googleClientSecret) {
|
|
3491
|
+
return reply.status(500).send("Google OAuth not configured");
|
|
3492
|
+
}
|
|
3493
|
+
const { code, state, error } = request.query;
|
|
3494
|
+
if (error) {
|
|
3495
|
+
const safeError = String(error).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
3496
|
+
return reply.type("text/html").send(`<html><body><h2>Authorization failed</h2><p>${safeError}</p><p>You can close this tab.</p></body></html>`);
|
|
3497
|
+
}
|
|
3498
|
+
if (!code || !state) {
|
|
3499
|
+
return reply.status(400).send("Missing code or state parameter");
|
|
3500
|
+
}
|
|
3501
|
+
const stateData = verifySignedState(state, stateSecret);
|
|
3502
|
+
if (!stateData) {
|
|
3503
|
+
return reply.status(400).send("Invalid or tampered state parameter");
|
|
3504
|
+
}
|
|
3505
|
+
const { domain, type, propertyId, redirectUri } = stateData;
|
|
3506
|
+
const tokens = await exchangeCode(googleClientId, googleClientSecret, code, redirectUri);
|
|
3507
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3508
|
+
const expiresAt = new Date(Date.now() + tokens.expires_in * 1e3).toISOString();
|
|
3509
|
+
const existing = app.db.select().from(googleConnections).where(and4(eq12(googleConnections.domain, domain), eq12(googleConnections.connectionType, type))).get();
|
|
3510
|
+
if (existing) {
|
|
3511
|
+
app.db.update(googleConnections).set({
|
|
3512
|
+
accessToken: tokens.access_token,
|
|
3513
|
+
refreshToken: tokens.refresh_token ?? existing.refreshToken,
|
|
3514
|
+
tokenExpiresAt: expiresAt,
|
|
3515
|
+
propertyId: propertyId ?? existing.propertyId,
|
|
3516
|
+
scopes: JSON.stringify(tokens.scope?.split(" ") ?? []),
|
|
3517
|
+
updatedAt: now
|
|
3518
|
+
}).where(eq12(googleConnections.id, existing.id)).run();
|
|
3519
|
+
} else {
|
|
3520
|
+
app.db.insert(googleConnections).values({
|
|
3521
|
+
id: crypto12.randomUUID(),
|
|
3522
|
+
domain,
|
|
3523
|
+
connectionType: type,
|
|
3524
|
+
propertyId: propertyId ?? null,
|
|
3525
|
+
accessToken: tokens.access_token,
|
|
3526
|
+
refreshToken: tokens.refresh_token ?? null,
|
|
3527
|
+
tokenExpiresAt: expiresAt,
|
|
3528
|
+
scopes: JSON.stringify(tokens.scope?.split(" ") ?? []),
|
|
3529
|
+
createdAt: now,
|
|
3530
|
+
updatedAt: now
|
|
3531
|
+
}).run();
|
|
3532
|
+
}
|
|
3533
|
+
writeAuditLog(app.db, {
|
|
3534
|
+
projectId: null,
|
|
3535
|
+
actor: "oauth",
|
|
3536
|
+
action: "google.connected",
|
|
3537
|
+
entityType: "google_connection",
|
|
3538
|
+
entityId: type,
|
|
3539
|
+
diff: { domain, type, propertyId }
|
|
3540
|
+
});
|
|
3541
|
+
return reply.type("text/html").send(
|
|
3542
|
+
`<html><body style="font-family:system-ui;text-align:center;padding:60px">
|
|
3543
|
+
<h2>Connected successfully!</h2>
|
|
3544
|
+
<p>Google ${type.toUpperCase()} has been linked to your domain.</p>
|
|
3545
|
+
<p style="color:#888">You can close this tab.</p>
|
|
3546
|
+
</body></html>`
|
|
3547
|
+
);
|
|
3548
|
+
});
|
|
3549
|
+
app.delete("/projects/:name/google/connections/:type", async (request, reply) => {
|
|
3550
|
+
const project = resolveProject(app.db, request.params.name);
|
|
3551
|
+
const deleted = app.db.delete(googleConnections).where(and4(eq12(googleConnections.domain, project.canonicalDomain), eq12(googleConnections.connectionType, request.params.type))).run();
|
|
3552
|
+
if (deleted.changes === 0) {
|
|
3553
|
+
const err = notFound("Google connection", request.params.type);
|
|
3554
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
3555
|
+
}
|
|
3556
|
+
writeAuditLog(app.db, {
|
|
3557
|
+
projectId: project.id,
|
|
3558
|
+
actor: "api",
|
|
3559
|
+
action: "google.disconnected",
|
|
3560
|
+
entityType: "google_connection",
|
|
3561
|
+
entityId: request.params.type
|
|
3562
|
+
});
|
|
3563
|
+
return reply.status(204).send();
|
|
3564
|
+
});
|
|
3565
|
+
app.get("/projects/:name/google/properties", async (request, reply) => {
|
|
3566
|
+
if (!googleClientId || !googleClientSecret) {
|
|
3567
|
+
const err = validationError("Google OAuth is not configured");
|
|
3568
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
3569
|
+
}
|
|
3570
|
+
const project = resolveProject(app.db, request.params.name);
|
|
3571
|
+
const { accessToken } = await getValidToken(app, project.canonicalDomain, "gsc", googleClientId, googleClientSecret);
|
|
3572
|
+
const sites = await listSites(accessToken);
|
|
3573
|
+
return { sites };
|
|
3574
|
+
});
|
|
3575
|
+
app.post("/projects/:name/google/gsc/sync", async (request, reply) => {
|
|
3576
|
+
const project = resolveProject(app.db, request.params.name);
|
|
3577
|
+
const conn = app.db.select().from(googleConnections).where(and4(eq12(googleConnections.domain, project.canonicalDomain), eq12(googleConnections.connectionType, "gsc"))).get();
|
|
3578
|
+
if (!conn) {
|
|
3579
|
+
const err = validationError('No GSC connection found for this domain. Run "canonry google connect" first.');
|
|
3580
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
3581
|
+
}
|
|
3582
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3583
|
+
const runId = crypto12.randomUUID();
|
|
3584
|
+
app.db.insert(runs).values({
|
|
3585
|
+
id: runId,
|
|
3586
|
+
projectId: project.id,
|
|
3587
|
+
kind: "gsc-sync",
|
|
3588
|
+
status: "queued",
|
|
3589
|
+
trigger: "manual",
|
|
3590
|
+
createdAt: now
|
|
3591
|
+
}).run();
|
|
3592
|
+
const { days, full } = request.body ?? {};
|
|
3593
|
+
if (opts.onGscSyncRequested) {
|
|
3594
|
+
opts.onGscSyncRequested(runId, project.id, { days, full });
|
|
3595
|
+
}
|
|
3596
|
+
const run = app.db.select().from(runs).where(eq12(runs.id, runId)).get();
|
|
3597
|
+
return run;
|
|
3598
|
+
});
|
|
3599
|
+
app.get("/projects/:name/google/gsc/performance", async (request) => {
|
|
3600
|
+
const project = resolveProject(app.db, request.params.name);
|
|
3601
|
+
const { startDate, endDate, query, page, limit } = request.query;
|
|
3602
|
+
const conditions = [eq12(gscSearchData.projectId, project.id)];
|
|
3603
|
+
if (startDate) conditions.push(sql2`${gscSearchData.date} >= ${startDate}`);
|
|
3604
|
+
if (endDate) conditions.push(sql2`${gscSearchData.date} <= ${endDate}`);
|
|
3605
|
+
if (query) conditions.push(sql2`${gscSearchData.query} LIKE ${"%" + query + "%"}`);
|
|
3606
|
+
if (page) conditions.push(sql2`${gscSearchData.page} LIKE ${"%" + page + "%"}`);
|
|
3607
|
+
const rows = app.db.select().from(gscSearchData).where(and4(...conditions)).orderBy(desc2(gscSearchData.date)).limit(parseInt(limit ?? "500", 10)).all();
|
|
3608
|
+
return rows.map((r) => ({
|
|
3609
|
+
date: r.date,
|
|
3610
|
+
query: r.query,
|
|
3611
|
+
page: r.page,
|
|
3612
|
+
country: r.country,
|
|
3613
|
+
device: r.device,
|
|
3614
|
+
clicks: r.clicks,
|
|
3615
|
+
impressions: r.impressions,
|
|
3616
|
+
ctr: parseFloat(r.ctr),
|
|
3617
|
+
position: parseFloat(r.position)
|
|
3618
|
+
}));
|
|
3619
|
+
});
|
|
3620
|
+
app.post("/projects/:name/google/gsc/inspect", async (request, reply) => {
|
|
3621
|
+
if (!googleClientId || !googleClientSecret) {
|
|
3622
|
+
const err = validationError("Google OAuth is not configured");
|
|
3623
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
3624
|
+
}
|
|
3625
|
+
const project = resolveProject(app.db, request.params.name);
|
|
3626
|
+
const { url } = request.body ?? {};
|
|
3627
|
+
if (!url) {
|
|
3628
|
+
const err = validationError("url is required");
|
|
3629
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
3630
|
+
}
|
|
3631
|
+
const { accessToken, propertyId } = await getValidToken(app, project.canonicalDomain, "gsc", googleClientId, googleClientSecret);
|
|
3632
|
+
if (!propertyId) {
|
|
3633
|
+
const err = validationError("No GSC property configured for this connection");
|
|
3634
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
3635
|
+
}
|
|
3636
|
+
const result = await inspectUrl(accessToken, url, propertyId);
|
|
3637
|
+
const ir = result.inspectionResult;
|
|
3638
|
+
const idx = ir.indexStatusResult;
|
|
3639
|
+
const mob = ir.mobileUsabilityResult;
|
|
3640
|
+
const rich = ir.richResultsResult;
|
|
3641
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3642
|
+
const id = crypto12.randomUUID();
|
|
3643
|
+
app.db.insert(gscUrlInspections).values({
|
|
3644
|
+
id,
|
|
3645
|
+
projectId: project.id,
|
|
3646
|
+
syncRunId: null,
|
|
3647
|
+
url,
|
|
3648
|
+
indexingState: idx?.indexingState ?? null,
|
|
3649
|
+
verdict: idx?.verdict ?? null,
|
|
3650
|
+
coverageState: idx?.coverageState ?? null,
|
|
3651
|
+
pageFetchState: idx?.pageFetchState ?? null,
|
|
3652
|
+
robotsTxtState: idx?.robotsTxtState ?? null,
|
|
3653
|
+
crawlTime: idx?.lastCrawlTime ?? null,
|
|
3654
|
+
lastCrawlResult: idx?.crawlResult ?? null,
|
|
3655
|
+
isMobileFriendly: mob?.verdict === "PASS" ? 1 : mob?.verdict === "FAIL" ? 0 : null,
|
|
3656
|
+
richResults: JSON.stringify(rich?.detectedItems?.map((d) => d.richResultType) ?? []),
|
|
3657
|
+
referringUrls: JSON.stringify(idx?.referringUrls ?? []),
|
|
3658
|
+
inspectedAt: now,
|
|
3659
|
+
createdAt: now
|
|
3660
|
+
}).run();
|
|
3661
|
+
return {
|
|
3662
|
+
id,
|
|
3663
|
+
url,
|
|
3664
|
+
indexingState: idx?.indexingState,
|
|
3665
|
+
verdict: idx?.verdict,
|
|
3666
|
+
coverageState: idx?.coverageState,
|
|
3667
|
+
pageFetchState: idx?.pageFetchState,
|
|
3668
|
+
robotsTxtState: idx?.robotsTxtState,
|
|
3669
|
+
crawlTime: idx?.lastCrawlTime,
|
|
3670
|
+
lastCrawlResult: idx?.crawlResult,
|
|
3671
|
+
isMobileFriendly: mob?.verdict === "PASS",
|
|
3672
|
+
richResults: rich?.detectedItems?.map((d) => d.richResultType) ?? [],
|
|
3673
|
+
referringUrls: idx?.referringUrls ?? [],
|
|
3674
|
+
inspectedAt: now
|
|
3675
|
+
};
|
|
3676
|
+
});
|
|
3677
|
+
app.get("/projects/:name/google/gsc/inspections", async (request) => {
|
|
3678
|
+
const project = resolveProject(app.db, request.params.name);
|
|
3679
|
+
const { url, limit } = request.query;
|
|
3680
|
+
const conditions = [eq12(gscUrlInspections.projectId, project.id)];
|
|
3681
|
+
if (url) conditions.push(eq12(gscUrlInspections.url, url));
|
|
3682
|
+
const rows = app.db.select().from(gscUrlInspections).where(and4(...conditions)).orderBy(desc2(gscUrlInspections.inspectedAt)).limit(parseInt(limit ?? "100", 10)).all();
|
|
3683
|
+
return rows.map((r) => ({
|
|
3684
|
+
id: r.id,
|
|
3685
|
+
url: r.url,
|
|
3686
|
+
indexingState: r.indexingState,
|
|
3687
|
+
verdict: r.verdict,
|
|
3688
|
+
coverageState: r.coverageState,
|
|
3689
|
+
pageFetchState: r.pageFetchState,
|
|
3690
|
+
robotsTxtState: r.robotsTxtState,
|
|
3691
|
+
crawlTime: r.crawlTime,
|
|
3692
|
+
lastCrawlResult: r.lastCrawlResult,
|
|
3693
|
+
isMobileFriendly: r.isMobileFriendly === 1 ? true : r.isMobileFriendly === 0 ? false : null,
|
|
3694
|
+
richResults: JSON.parse(r.richResults),
|
|
3695
|
+
referringUrls: JSON.parse(r.referringUrls),
|
|
3696
|
+
inspectedAt: r.inspectedAt
|
|
3697
|
+
}));
|
|
3698
|
+
});
|
|
3699
|
+
app.get("/projects/:name/google/gsc/deindexed", async (request) => {
|
|
3700
|
+
const project = resolveProject(app.db, request.params.name);
|
|
3701
|
+
const allInspections = app.db.select().from(gscUrlInspections).where(eq12(gscUrlInspections.projectId, project.id)).orderBy(desc2(gscUrlInspections.inspectedAt)).all();
|
|
3702
|
+
const byUrl = /* @__PURE__ */ new Map();
|
|
3703
|
+
for (const row of allInspections) {
|
|
3704
|
+
const existing = byUrl.get(row.url);
|
|
3705
|
+
if (existing) {
|
|
3706
|
+
existing.push(row);
|
|
3707
|
+
} else {
|
|
3708
|
+
byUrl.set(row.url, [row]);
|
|
3709
|
+
}
|
|
3710
|
+
}
|
|
3711
|
+
const deindexed = [];
|
|
3712
|
+
for (const [url, inspections] of byUrl) {
|
|
3713
|
+
if (inspections.length < 2) continue;
|
|
3714
|
+
const latest = inspections[0];
|
|
3715
|
+
const previous = inspections[1];
|
|
3716
|
+
if (previous.indexingState?.toUpperCase() === "INDEXED" && latest.indexingState?.toUpperCase() !== "INDEXED") {
|
|
3717
|
+
deindexed.push({
|
|
3718
|
+
url,
|
|
3719
|
+
previousState: previous.indexingState,
|
|
3720
|
+
currentState: latest.indexingState,
|
|
3721
|
+
transitionDate: latest.inspectedAt
|
|
3722
|
+
});
|
|
3723
|
+
}
|
|
3724
|
+
}
|
|
3725
|
+
return deindexed;
|
|
3726
|
+
});
|
|
3727
|
+
app.put("/projects/:name/google/connections/:type/property", async (request, reply) => {
|
|
3728
|
+
const project = resolveProject(app.db, request.params.name);
|
|
3729
|
+
const { propertyId } = request.body ?? {};
|
|
3730
|
+
if (!propertyId) {
|
|
3731
|
+
const err = validationError("propertyId is required");
|
|
3732
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
3733
|
+
}
|
|
3734
|
+
const conn = app.db.select().from(googleConnections).where(and4(eq12(googleConnections.domain, project.canonicalDomain), eq12(googleConnections.connectionType, request.params.type))).get();
|
|
3735
|
+
if (!conn) {
|
|
3736
|
+
const err = notFound("Google connection", request.params.type);
|
|
3737
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
3738
|
+
}
|
|
3739
|
+
app.db.update(googleConnections).set({ propertyId, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq12(googleConnections.id, conn.id)).run();
|
|
3740
|
+
return { propertyId };
|
|
3741
|
+
});
|
|
3742
|
+
}
|
|
3743
|
+
|
|
3007
3744
|
// ../api-routes/src/index.ts
|
|
3008
3745
|
async function apiRoutes(app, opts) {
|
|
3009
3746
|
app.decorate("db", opts.db);
|
|
@@ -3036,16 +3773,22 @@ async function apiRoutes(app, opts) {
|
|
|
3036
3773
|
getTelemetryStatus: opts.getTelemetryStatus,
|
|
3037
3774
|
setTelemetryEnabled: opts.setTelemetryEnabled
|
|
3038
3775
|
});
|
|
3776
|
+
await api.register(googleRoutes, {
|
|
3777
|
+
googleClientId: opts.googleClientId,
|
|
3778
|
+
googleClientSecret: opts.googleClientSecret,
|
|
3779
|
+
googleStateSecret: opts.googleStateSecret,
|
|
3780
|
+
onGscSyncRequested: opts.onGscSyncRequested
|
|
3781
|
+
});
|
|
3039
3782
|
}, { prefix: "/api/v1" });
|
|
3040
3783
|
}
|
|
3041
3784
|
|
|
3042
3785
|
// ../provider-gemini/src/normalize.ts
|
|
3043
3786
|
import { GoogleGenerativeAI } from "@google/generative-ai";
|
|
3044
|
-
var DEFAULT_MODEL = "gemini
|
|
3787
|
+
var DEFAULT_MODEL = getDefaultModel("gemini");
|
|
3045
3788
|
function resolveModel(config) {
|
|
3046
3789
|
const m = config.model;
|
|
3047
3790
|
if (!m) return DEFAULT_MODEL;
|
|
3048
|
-
if (
|
|
3791
|
+
if (isValidModelName("gemini", m)) return m;
|
|
3049
3792
|
console.warn(
|
|
3050
3793
|
`[provider-gemini] Invalid model name "${m}" \u2014 this provider uses the Gemini AI Studio API (generativelanguage.googleapis.com) which only accepts "gemini-*" model names. Falling back to ${DEFAULT_MODEL}.`
|
|
3051
3794
|
);
|
|
@@ -3056,7 +3799,7 @@ function validateConfig(config) {
|
|
|
3056
3799
|
return { ok: false, provider: "gemini", message: "missing api key" };
|
|
3057
3800
|
}
|
|
3058
3801
|
const model = resolveModel(config);
|
|
3059
|
-
const warning = config.model && !
|
|
3802
|
+
const warning = config.model && !isValidModelName("gemini", config.model) ? ` (invalid model "${config.model}" replaced with default)` : "";
|
|
3060
3803
|
return {
|
|
3061
3804
|
ok: true,
|
|
3062
3805
|
provider: "gemini",
|
|
@@ -3294,7 +4037,7 @@ var geminiAdapter = {
|
|
|
3294
4037
|
|
|
3295
4038
|
// ../provider-openai/src/normalize.ts
|
|
3296
4039
|
import OpenAI from "openai";
|
|
3297
|
-
var DEFAULT_MODEL2 = "
|
|
4040
|
+
var DEFAULT_MODEL2 = getDefaultModel("openai");
|
|
3298
4041
|
function validateConfig2(config) {
|
|
3299
4042
|
if (!config.apiKey || config.apiKey.length === 0) {
|
|
3300
4043
|
return { ok: false, provider: "openai", message: "missing api key" };
|
|
@@ -3535,7 +4278,7 @@ var openaiAdapter = {
|
|
|
3535
4278
|
|
|
3536
4279
|
// ../provider-claude/src/normalize.ts
|
|
3537
4280
|
import Anthropic from "@anthropic-ai/sdk";
|
|
3538
|
-
var DEFAULT_MODEL3 = "claude
|
|
4281
|
+
var DEFAULT_MODEL3 = getDefaultModel("claude");
|
|
3539
4282
|
function validateConfig3(config) {
|
|
3540
4283
|
if (!config.apiKey || config.apiKey.length === 0) {
|
|
3541
4284
|
return { ok: false, provider: "claude", message: "missing api key" };
|
|
@@ -3772,7 +4515,7 @@ var claudeAdapter = {
|
|
|
3772
4515
|
|
|
3773
4516
|
// ../provider-local/src/normalize.ts
|
|
3774
4517
|
import OpenAI2 from "openai";
|
|
3775
|
-
var DEFAULT_MODEL4 = "
|
|
4518
|
+
var DEFAULT_MODEL4 = getDefaultModel("local");
|
|
3776
4519
|
function validateConfig4(config) {
|
|
3777
4520
|
if (!config.baseUrl || config.baseUrl.length === 0) {
|
|
3778
4521
|
return { ok: false, provider: "local", message: "missing base URL" };
|
|
@@ -3956,8 +4699,8 @@ var localAdapter = {
|
|
|
3956
4699
|
};
|
|
3957
4700
|
|
|
3958
4701
|
// src/job-runner.ts
|
|
3959
|
-
import
|
|
3960
|
-
import { eq as
|
|
4702
|
+
import crypto13 from "crypto";
|
|
4703
|
+
import { eq as eq13, inArray as inArray2 } from "drizzle-orm";
|
|
3961
4704
|
var JobRunner = class {
|
|
3962
4705
|
db;
|
|
3963
4706
|
registry;
|
|
@@ -3971,7 +4714,7 @@ var JobRunner = class {
|
|
|
3971
4714
|
if (stale.length === 0) return;
|
|
3972
4715
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3973
4716
|
for (const run of stale) {
|
|
3974
|
-
this.db.update(runs).set({ status: "failed", finishedAt: now, error: "Server restarted while run was in progress" }).where(
|
|
4717
|
+
this.db.update(runs).set({ status: "failed", finishedAt: now, error: "Server restarted while run was in progress" }).where(eq13(runs.id, run.id)).run();
|
|
3975
4718
|
console.log(`[JobRunner] Recovered stale run ${run.id} (was ${run.status})`);
|
|
3976
4719
|
}
|
|
3977
4720
|
}
|
|
@@ -3979,8 +4722,8 @@ var JobRunner = class {
|
|
|
3979
4722
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3980
4723
|
const startTime = Date.now();
|
|
3981
4724
|
try {
|
|
3982
|
-
this.db.update(runs).set({ status: "running", startedAt: now }).where(
|
|
3983
|
-
const project = this.db.select().from(projects).where(
|
|
4725
|
+
this.db.update(runs).set({ status: "running", startedAt: now }).where(eq13(runs.id, runId)).run();
|
|
4726
|
+
const project = this.db.select().from(projects).where(eq13(projects.id, projectId)).get();
|
|
3984
4727
|
if (!project) {
|
|
3985
4728
|
throw new Error(`Project ${projectId} not found`);
|
|
3986
4729
|
}
|
|
@@ -3990,14 +4733,14 @@ var JobRunner = class {
|
|
|
3990
4733
|
throw new Error("No providers configured. Add at least one provider API key.");
|
|
3991
4734
|
}
|
|
3992
4735
|
console.log(`[JobRunner] Run ${runId}: dispatching to ${activeProviders.length} providers: ${activeProviders.map((p) => p.adapter.name).join(", ")}`);
|
|
3993
|
-
const projectKeywords = this.db.select().from(keywords).where(
|
|
3994
|
-
const projectCompetitors = this.db.select().from(competitors).where(
|
|
4736
|
+
const projectKeywords = this.db.select().from(keywords).where(eq13(keywords.projectId, projectId)).all();
|
|
4737
|
+
const projectCompetitors = this.db.select().from(competitors).where(eq13(competitors.projectId, projectId)).all();
|
|
3995
4738
|
const competitorDomains = projectCompetitors.map((c) => c.domain);
|
|
3996
4739
|
const queriesPerProvider = projectKeywords.length;
|
|
3997
4740
|
const todayPeriod = getCurrentPeriod();
|
|
3998
4741
|
for (const p of activeProviders) {
|
|
3999
4742
|
const providerScope = `${projectId}:${p.adapter.name}`;
|
|
4000
|
-
const providerUsage = this.db.select().from(usageCounters).where(
|
|
4743
|
+
const providerUsage = this.db.select().from(usageCounters).where(eq13(usageCounters.scope, providerScope)).all().filter((r) => r.period === todayPeriod && r.metric === "queries").reduce((sum, r) => sum + r.count, 0);
|
|
4001
4744
|
const limit = p.config.quotaPolicy.maxRequestsPerDay;
|
|
4002
4745
|
if (providerUsage + queriesPerProvider > limit) {
|
|
4003
4746
|
throw new Error(
|
|
@@ -4037,10 +4780,11 @@ var JobRunner = class {
|
|
|
4037
4780
|
const citationState = determineCitationState(normalized, allDomains);
|
|
4038
4781
|
const overlap = computeCompetitorOverlap(normalized, competitorDomains);
|
|
4039
4782
|
this.db.insert(querySnapshots).values({
|
|
4040
|
-
id:
|
|
4783
|
+
id: crypto13.randomUUID(),
|
|
4041
4784
|
runId,
|
|
4042
4785
|
keywordId: kw.id,
|
|
4043
4786
|
provider: providerName,
|
|
4787
|
+
model: raw.model,
|
|
4044
4788
|
citationState,
|
|
4045
4789
|
answerText: normalized.answerText,
|
|
4046
4790
|
citedDomains: JSON.stringify(normalized.citedDomains),
|
|
@@ -4067,12 +4811,12 @@ var JobRunner = class {
|
|
|
4067
4811
|
const someFailed = providerErrors.size > 0;
|
|
4068
4812
|
if (allFailed) {
|
|
4069
4813
|
const errorDetail = JSON.stringify(Object.fromEntries(providerErrors));
|
|
4070
|
-
this.db.update(runs).set({ status: "failed", finishedAt: (/* @__PURE__ */ new Date()).toISOString(), error: errorDetail }).where(
|
|
4814
|
+
this.db.update(runs).set({ status: "failed", finishedAt: (/* @__PURE__ */ new Date()).toISOString(), error: errorDetail }).where(eq13(runs.id, runId)).run();
|
|
4071
4815
|
} else if (someFailed) {
|
|
4072
4816
|
const errorDetail = JSON.stringify(Object.fromEntries(providerErrors));
|
|
4073
|
-
this.db.update(runs).set({ status: "partial", finishedAt: (/* @__PURE__ */ new Date()).toISOString(), error: errorDetail }).where(
|
|
4817
|
+
this.db.update(runs).set({ status: "partial", finishedAt: (/* @__PURE__ */ new Date()).toISOString(), error: errorDetail }).where(eq13(runs.id, runId)).run();
|
|
4074
4818
|
} else {
|
|
4075
|
-
this.db.update(runs).set({ status: "completed", finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(
|
|
4819
|
+
this.db.update(runs).set({ status: "completed", finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq13(runs.id, runId)).run();
|
|
4076
4820
|
}
|
|
4077
4821
|
const finalStatus = allFailed ? "failed" : someFailed ? "partial" : "completed";
|
|
4078
4822
|
trackEvent("run.completed", {
|
|
@@ -4097,7 +4841,7 @@ var JobRunner = class {
|
|
|
4097
4841
|
status: "failed",
|
|
4098
4842
|
finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4099
4843
|
error: errorMessage
|
|
4100
|
-
}).where(
|
|
4844
|
+
}).where(eq13(runs.id, runId)).run();
|
|
4101
4845
|
trackEvent("run.completed", {
|
|
4102
4846
|
status: "failed",
|
|
4103
4847
|
providerCount: 0,
|
|
@@ -4133,10 +4877,10 @@ var JobRunner = class {
|
|
|
4133
4877
|
incrementUsage(scope, metric, count) {
|
|
4134
4878
|
const now = /* @__PURE__ */ new Date();
|
|
4135
4879
|
const period = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
|
|
4136
|
-
const id =
|
|
4137
|
-
const existing = this.db.select().from(usageCounters).where(
|
|
4880
|
+
const id = crypto13.randomUUID();
|
|
4881
|
+
const existing = this.db.select().from(usageCounters).where(eq13(usageCounters.scope, scope)).all().find((r) => r.period === period && r.metric === metric);
|
|
4138
4882
|
if (existing) {
|
|
4139
|
-
this.db.update(usageCounters).set({ count: existing.count + count, updatedAt: now.toISOString() }).where(
|
|
4883
|
+
this.db.update(usageCounters).set({ count: existing.count + count, updatedAt: now.toISOString() }).where(eq13(usageCounters.id, existing.id)).run();
|
|
4140
4884
|
} else {
|
|
4141
4885
|
this.db.insert(usageCounters).values({
|
|
4142
4886
|
id,
|
|
@@ -4215,6 +4959,132 @@ function computeCompetitorOverlap(normalized, competitorDomains) {
|
|
|
4215
4959
|
return [...overlapSet];
|
|
4216
4960
|
}
|
|
4217
4961
|
|
|
4962
|
+
// src/gsc-sync.ts
|
|
4963
|
+
import crypto14 from "crypto";
|
|
4964
|
+
import { eq as eq14, and as and5, sql as sql3 } from "drizzle-orm";
|
|
4965
|
+
function formatDate(d) {
|
|
4966
|
+
return d.toISOString().split("T")[0];
|
|
4967
|
+
}
|
|
4968
|
+
function daysAgo(n) {
|
|
4969
|
+
const d = /* @__PURE__ */ new Date();
|
|
4970
|
+
d.setDate(d.getDate() - n);
|
|
4971
|
+
return d;
|
|
4972
|
+
}
|
|
4973
|
+
async function executeGscSync(db, runId, projectId, opts) {
|
|
4974
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
4975
|
+
db.update(runs).set({ status: "running", startedAt: now }).where(eq14(runs.id, runId)).run();
|
|
4976
|
+
try {
|
|
4977
|
+
const project = db.select().from(projects).where(eq14(projects.id, projectId)).get();
|
|
4978
|
+
if (!project) {
|
|
4979
|
+
throw new Error(`Project not found: ${projectId}`);
|
|
4980
|
+
}
|
|
4981
|
+
const conn = db.select().from(googleConnections).where(and5(eq14(googleConnections.domain, project.canonicalDomain), eq14(googleConnections.connectionType, "gsc"))).get();
|
|
4982
|
+
if (!conn || !conn.refreshToken) {
|
|
4983
|
+
throw new Error("No GSC connection found or connection is incomplete");
|
|
4984
|
+
}
|
|
4985
|
+
if (!conn.propertyId) {
|
|
4986
|
+
throw new Error('No GSC property selected. Use "canonry google properties" to list available sites, then set one with the API.');
|
|
4987
|
+
}
|
|
4988
|
+
let accessToken = conn.accessToken;
|
|
4989
|
+
const expiresAt = conn.tokenExpiresAt ? new Date(conn.tokenExpiresAt).getTime() : 0;
|
|
4990
|
+
if (Date.now() > expiresAt - 5 * 60 * 1e3) {
|
|
4991
|
+
const tokens = await refreshAccessToken(opts.googleClientId, opts.googleClientSecret, conn.refreshToken);
|
|
4992
|
+
accessToken = tokens.access_token;
|
|
4993
|
+
db.update(googleConnections).set({
|
|
4994
|
+
accessToken: tokens.access_token,
|
|
4995
|
+
tokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1e3).toISOString(),
|
|
4996
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
4997
|
+
}).where(eq14(googleConnections.id, conn.id)).run();
|
|
4998
|
+
}
|
|
4999
|
+
const lagOffset = GSC_DATA_LAG_DAYS;
|
|
5000
|
+
const endDate = formatDate(daysAgo(lagOffset));
|
|
5001
|
+
const days = opts.full ? 480 : opts.days ?? 30;
|
|
5002
|
+
const startDate = formatDate(daysAgo(days + lagOffset));
|
|
5003
|
+
console.log(`[GSC Sync] Fetching search analytics for ${conn.propertyId} from ${startDate} to ${endDate}`);
|
|
5004
|
+
const rows = await fetchSearchAnalytics(accessToken, conn.propertyId, {
|
|
5005
|
+
startDate,
|
|
5006
|
+
endDate
|
|
5007
|
+
});
|
|
5008
|
+
console.log(`[GSC Sync] Received ${rows.length} rows`);
|
|
5009
|
+
db.delete(gscSearchData).where(
|
|
5010
|
+
and5(
|
|
5011
|
+
eq14(gscSearchData.projectId, projectId),
|
|
5012
|
+
sql3`${gscSearchData.date} >= ${startDate}`,
|
|
5013
|
+
sql3`${gscSearchData.date} <= ${endDate}`
|
|
5014
|
+
)
|
|
5015
|
+
).run();
|
|
5016
|
+
const batchSize = 500;
|
|
5017
|
+
for (let i = 0; i < rows.length; i += batchSize) {
|
|
5018
|
+
const batch = rows.slice(i, i + batchSize);
|
|
5019
|
+
const insertNow = (/* @__PURE__ */ new Date()).toISOString();
|
|
5020
|
+
for (const row of batch) {
|
|
5021
|
+
const [query, page, country, device, date] = row.keys;
|
|
5022
|
+
db.insert(gscSearchData).values({
|
|
5023
|
+
id: crypto14.randomUUID(),
|
|
5024
|
+
projectId,
|
|
5025
|
+
syncRunId: runId,
|
|
5026
|
+
date: date ?? "",
|
|
5027
|
+
query: query ?? "",
|
|
5028
|
+
page: page ?? "",
|
|
5029
|
+
country: country ?? null,
|
|
5030
|
+
device: device ?? null,
|
|
5031
|
+
clicks: row.clicks,
|
|
5032
|
+
impressions: row.impressions,
|
|
5033
|
+
ctr: String(row.ctr),
|
|
5034
|
+
position: String(row.position),
|
|
5035
|
+
createdAt: insertNow
|
|
5036
|
+
}).run();
|
|
5037
|
+
}
|
|
5038
|
+
}
|
|
5039
|
+
const pageClicks = /* @__PURE__ */ new Map();
|
|
5040
|
+
for (const row of rows) {
|
|
5041
|
+
const page = row.keys[1];
|
|
5042
|
+
if (page) {
|
|
5043
|
+
pageClicks.set(page, (pageClicks.get(page) ?? 0) + row.clicks);
|
|
5044
|
+
}
|
|
5045
|
+
}
|
|
5046
|
+
const topPages = [...pageClicks.entries()].sort((a, b) => b[1] - a[1]).slice(0, 50).map(([page]) => page);
|
|
5047
|
+
console.log(`[GSC Sync] Inspecting ${topPages.length} URLs`);
|
|
5048
|
+
for (const pageUrl of topPages) {
|
|
5049
|
+
try {
|
|
5050
|
+
const result = await inspectUrl(accessToken, pageUrl, conn.propertyId);
|
|
5051
|
+
const ir = result.inspectionResult;
|
|
5052
|
+
const idx = ir.indexStatusResult;
|
|
5053
|
+
const mob = ir.mobileUsabilityResult;
|
|
5054
|
+
const rich = ir.richResultsResult;
|
|
5055
|
+
const inspectedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5056
|
+
db.insert(gscUrlInspections).values({
|
|
5057
|
+
id: crypto14.randomUUID(),
|
|
5058
|
+
projectId,
|
|
5059
|
+
syncRunId: runId,
|
|
5060
|
+
url: pageUrl,
|
|
5061
|
+
indexingState: idx?.indexingState ?? null,
|
|
5062
|
+
verdict: idx?.verdict ?? null,
|
|
5063
|
+
coverageState: idx?.coverageState ?? null,
|
|
5064
|
+
pageFetchState: idx?.pageFetchState ?? null,
|
|
5065
|
+
robotsTxtState: idx?.robotsTxtState ?? null,
|
|
5066
|
+
crawlTime: idx?.lastCrawlTime ?? null,
|
|
5067
|
+
lastCrawlResult: idx?.crawlResult ?? null,
|
|
5068
|
+
isMobileFriendly: mob?.verdict === "PASS" ? 1 : mob?.verdict === "FAIL" ? 0 : null,
|
|
5069
|
+
richResults: JSON.stringify(rich?.detectedItems?.map((d) => d.richResultType) ?? []),
|
|
5070
|
+
referringUrls: JSON.stringify(idx?.referringUrls ?? []),
|
|
5071
|
+
inspectedAt,
|
|
5072
|
+
createdAt: inspectedAt
|
|
5073
|
+
}).run();
|
|
5074
|
+
} catch (err) {
|
|
5075
|
+
console.error(`[GSC Sync] Failed to inspect ${pageUrl}:`, err instanceof Error ? err.message : err);
|
|
5076
|
+
}
|
|
5077
|
+
}
|
|
5078
|
+
db.update(runs).set({ status: "completed", finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq14(runs.id, runId)).run();
|
|
5079
|
+
console.log(`[GSC Sync] Completed. ${rows.length} search data rows, ${topPages.length} URL inspections.`);
|
|
5080
|
+
} catch (err) {
|
|
5081
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
5082
|
+
db.update(runs).set({ status: "failed", error: errorMsg, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq14(runs.id, runId)).run();
|
|
5083
|
+
console.error(`[GSC Sync] Failed:`, errorMsg);
|
|
5084
|
+
throw err;
|
|
5085
|
+
}
|
|
5086
|
+
}
|
|
5087
|
+
|
|
4218
5088
|
// src/provider-registry.ts
|
|
4219
5089
|
var ProviderRegistry = class {
|
|
4220
5090
|
providers = /* @__PURE__ */ new Map();
|
|
@@ -4260,7 +5130,7 @@ var ProviderRegistry = class {
|
|
|
4260
5130
|
|
|
4261
5131
|
// src/scheduler.ts
|
|
4262
5132
|
import cron from "node-cron";
|
|
4263
|
-
import { eq as
|
|
5133
|
+
import { eq as eq15 } from "drizzle-orm";
|
|
4264
5134
|
var Scheduler = class {
|
|
4265
5135
|
db;
|
|
4266
5136
|
callbacks;
|
|
@@ -4271,7 +5141,7 @@ var Scheduler = class {
|
|
|
4271
5141
|
}
|
|
4272
5142
|
/** Load all enabled schedules from DB and register cron jobs. */
|
|
4273
5143
|
start() {
|
|
4274
|
-
const allSchedules = this.db.select().from(schedules).where(
|
|
5144
|
+
const allSchedules = this.db.select().from(schedules).where(eq15(schedules.enabled, 1)).all();
|
|
4275
5145
|
for (const schedule of allSchedules) {
|
|
4276
5146
|
const missedRunAt = schedule.nextRunAt;
|
|
4277
5147
|
this.registerCronTask(schedule);
|
|
@@ -4297,7 +5167,7 @@ var Scheduler = class {
|
|
|
4297
5167
|
existing.stop();
|
|
4298
5168
|
this.tasks.delete(projectId);
|
|
4299
5169
|
}
|
|
4300
|
-
const schedule = this.db.select().from(schedules).where(
|
|
5170
|
+
const schedule = this.db.select().from(schedules).where(eq15(schedules.projectId, projectId)).get();
|
|
4301
5171
|
if (schedule && schedule.enabled === 1) {
|
|
4302
5172
|
this.registerCronTask(schedule);
|
|
4303
5173
|
}
|
|
@@ -4326,13 +5196,13 @@ var Scheduler = class {
|
|
|
4326
5196
|
this.db.update(schedules).set({
|
|
4327
5197
|
nextRunAt: task.getNextRun()?.toISOString() ?? null,
|
|
4328
5198
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
4329
|
-
}).where(
|
|
5199
|
+
}).where(eq15(schedules.id, scheduleId)).run();
|
|
4330
5200
|
const label = schedule.preset ?? cronExpr;
|
|
4331
5201
|
console.log(`[Scheduler] Registered "${label}" (${timezone}) for project ${projectId}`);
|
|
4332
5202
|
}
|
|
4333
5203
|
triggerRun(scheduleId, projectId) {
|
|
4334
5204
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
4335
|
-
const currentSchedule = this.db.select().from(schedules).where(
|
|
5205
|
+
const currentSchedule = this.db.select().from(schedules).where(eq15(schedules.id, scheduleId)).get();
|
|
4336
5206
|
if (!currentSchedule || currentSchedule.enabled !== 1) {
|
|
4337
5207
|
console.log(`[Scheduler] Schedule ${scheduleId} no longer exists or is disabled, removing task for project ${projectId}`);
|
|
4338
5208
|
this.remove(projectId);
|
|
@@ -4340,7 +5210,7 @@ var Scheduler = class {
|
|
|
4340
5210
|
}
|
|
4341
5211
|
const task = this.tasks.get(projectId);
|
|
4342
5212
|
const nextRunAt = task?.getNextRun()?.toISOString() ?? null;
|
|
4343
|
-
const project = this.db.select().from(projects).where(
|
|
5213
|
+
const project = this.db.select().from(projects).where(eq15(projects.id, projectId)).get();
|
|
4344
5214
|
if (!project) {
|
|
4345
5215
|
console.error(`[Scheduler] Project ${projectId} not found, skipping scheduled run`);
|
|
4346
5216
|
this.remove(projectId);
|
|
@@ -4357,7 +5227,7 @@ var Scheduler = class {
|
|
|
4357
5227
|
this.db.update(schedules).set({
|
|
4358
5228
|
nextRunAt,
|
|
4359
5229
|
updatedAt: now
|
|
4360
|
-
}).where(
|
|
5230
|
+
}).where(eq15(schedules.id, currentSchedule.id)).run();
|
|
4361
5231
|
return;
|
|
4362
5232
|
}
|
|
4363
5233
|
const runId = queueResult.runId;
|
|
@@ -4365,7 +5235,7 @@ var Scheduler = class {
|
|
|
4365
5235
|
lastRunAt: now,
|
|
4366
5236
|
nextRunAt,
|
|
4367
5237
|
updatedAt: now
|
|
4368
|
-
}).where(
|
|
5238
|
+
}).where(eq15(schedules.id, currentSchedule.id)).run();
|
|
4369
5239
|
const scheduleProviders = JSON.parse(currentSchedule.providers);
|
|
4370
5240
|
const providers = scheduleProviders.length > 0 ? scheduleProviders : void 0;
|
|
4371
5241
|
console.log(`[Scheduler] Triggered scheduled run ${runId} for project ${project.name}`);
|
|
@@ -4374,8 +5244,8 @@ var Scheduler = class {
|
|
|
4374
5244
|
};
|
|
4375
5245
|
|
|
4376
5246
|
// src/notifier.ts
|
|
4377
|
-
import { eq as
|
|
4378
|
-
import
|
|
5247
|
+
import { eq as eq16, desc as desc3, and as and6, or as or2 } from "drizzle-orm";
|
|
5248
|
+
import crypto15 from "crypto";
|
|
4379
5249
|
var Notifier = class {
|
|
4380
5250
|
db;
|
|
4381
5251
|
serverUrl;
|
|
@@ -4386,18 +5256,18 @@ var Notifier = class {
|
|
|
4386
5256
|
/** Called after a run completes (success, partial, or failed). */
|
|
4387
5257
|
async onRunCompleted(runId, projectId) {
|
|
4388
5258
|
console.log(`[Notifier] onRunCompleted: runId=${runId} projectId=${projectId}`);
|
|
4389
|
-
const notifs = this.db.select().from(notifications).where(
|
|
5259
|
+
const notifs = this.db.select().from(notifications).where(eq16(notifications.projectId, projectId)).all().filter((n) => n.enabled === 1);
|
|
4390
5260
|
if (notifs.length === 0) {
|
|
4391
5261
|
console.log(`[Notifier] No enabled notifications for project ${projectId} \u2014 skipping`);
|
|
4392
5262
|
return;
|
|
4393
5263
|
}
|
|
4394
5264
|
console.log(`[Notifier] Found ${notifs.length} enabled notification(s) for project ${projectId}`);
|
|
4395
|
-
const run = this.db.select().from(runs).where(
|
|
5265
|
+
const run = this.db.select().from(runs).where(eq16(runs.id, runId)).get();
|
|
4396
5266
|
if (!run) {
|
|
4397
5267
|
console.error(`[Notifier] Run ${runId} not found \u2014 skipping notification dispatch`);
|
|
4398
5268
|
return;
|
|
4399
5269
|
}
|
|
4400
|
-
const project = this.db.select().from(projects).where(
|
|
5270
|
+
const project = this.db.select().from(projects).where(eq16(projects.id, projectId)).get();
|
|
4401
5271
|
if (!project) {
|
|
4402
5272
|
console.error(`[Notifier] Project ${projectId} not found \u2014 skipping notification dispatch`);
|
|
4403
5273
|
return;
|
|
@@ -4437,11 +5307,11 @@ var Notifier = class {
|
|
|
4437
5307
|
}
|
|
4438
5308
|
computeTransitions(runId, projectId) {
|
|
4439
5309
|
const recentRuns = this.db.select().from(runs).where(
|
|
4440
|
-
|
|
4441
|
-
|
|
4442
|
-
or2(
|
|
5310
|
+
and6(
|
|
5311
|
+
eq16(runs.projectId, projectId),
|
|
5312
|
+
or2(eq16(runs.status, "completed"), eq16(runs.status, "partial"))
|
|
4443
5313
|
)
|
|
4444
|
-
).orderBy(
|
|
5314
|
+
).orderBy(desc3(runs.createdAt)).limit(2).all();
|
|
4445
5315
|
if (recentRuns.length < 2) return [];
|
|
4446
5316
|
const currentRunId = recentRuns[0].id;
|
|
4447
5317
|
const previousRunId = recentRuns[1].id;
|
|
@@ -4451,12 +5321,12 @@ var Notifier = class {
|
|
|
4451
5321
|
keyword: keywords.keyword,
|
|
4452
5322
|
provider: querySnapshots.provider,
|
|
4453
5323
|
citationState: querySnapshots.citationState
|
|
4454
|
-
}).from(querySnapshots).leftJoin(keywords,
|
|
5324
|
+
}).from(querySnapshots).leftJoin(keywords, eq16(querySnapshots.keywordId, keywords.id)).where(eq16(querySnapshots.runId, currentRunId)).all();
|
|
4455
5325
|
const previousSnapshots = this.db.select({
|
|
4456
5326
|
keywordId: querySnapshots.keywordId,
|
|
4457
5327
|
provider: querySnapshots.provider,
|
|
4458
5328
|
citationState: querySnapshots.citationState
|
|
4459
|
-
}).from(querySnapshots).where(
|
|
5329
|
+
}).from(querySnapshots).where(eq16(querySnapshots.runId, previousRunId)).all();
|
|
4460
5330
|
const prevMap = /* @__PURE__ */ new Map();
|
|
4461
5331
|
for (const s of previousSnapshots) {
|
|
4462
5332
|
prevMap.set(`${s.keywordId}:${s.provider}`, s.citationState);
|
|
@@ -4513,7 +5383,7 @@ var Notifier = class {
|
|
|
4513
5383
|
}
|
|
4514
5384
|
logDelivery(projectId, notificationId, event, status, error) {
|
|
4515
5385
|
this.db.insert(auditLog).values({
|
|
4516
|
-
id:
|
|
5386
|
+
id: crypto15.randomUUID(),
|
|
4517
5387
|
projectId,
|
|
4518
5388
|
actor: "scheduler",
|
|
4519
5389
|
action: `notification.${status}`,
|
|
@@ -4724,9 +5594,28 @@ async function createServer(opts) {
|
|
|
4724
5594
|
quota: registry.get(name)?.config.quotaPolicy
|
|
4725
5595
|
}));
|
|
4726
5596
|
const adapterMap = { gemini: geminiAdapter, openai: openaiAdapter, claude: claudeAdapter, local: localAdapter };
|
|
5597
|
+
const googleClientId = process.env.GOOGLE_CLIENT_ID;
|
|
5598
|
+
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;
|
|
5599
|
+
const googleStateSecret = process.env.GOOGLE_STATE_SECRET ?? crypto16.randomBytes(32).toString("hex");
|
|
4727
5600
|
await app.register(apiRoutes, {
|
|
4728
5601
|
db: opts.db,
|
|
4729
5602
|
skipAuth: false,
|
|
5603
|
+
googleClientId,
|
|
5604
|
+
googleClientSecret,
|
|
5605
|
+
googleStateSecret,
|
|
5606
|
+
onGscSyncRequested: (runId, projectId, syncOpts) => {
|
|
5607
|
+
if (!googleClientId || !googleClientSecret) {
|
|
5608
|
+
app.log.error("GSC sync requested but GOOGLE_CLIENT_ID/SECRET not configured");
|
|
5609
|
+
return;
|
|
5610
|
+
}
|
|
5611
|
+
executeGscSync(opts.db, runId, projectId, {
|
|
5612
|
+
...syncOpts,
|
|
5613
|
+
googleClientId,
|
|
5614
|
+
googleClientSecret
|
|
5615
|
+
}).catch((err) => {
|
|
5616
|
+
app.log.error({ runId, err }, "GSC sync failed");
|
|
5617
|
+
});
|
|
5618
|
+
},
|
|
4730
5619
|
openApiInfo: {
|
|
4731
5620
|
title: "Canonry API",
|
|
4732
5621
|
version: PKG_VERSION
|
|
@@ -4910,6 +5799,7 @@ function parseKeywordResponse(raw, count) {
|
|
|
4910
5799
|
export {
|
|
4911
5800
|
providerQuotaPolicySchema,
|
|
4912
5801
|
notificationEventSchema,
|
|
5802
|
+
getDefaultModel,
|
|
4913
5803
|
effectiveDomains,
|
|
4914
5804
|
apiKeys,
|
|
4915
5805
|
createClient,
|