From e2c84803f2ca12e8a18168274dd851c01f7d6062 Mon Sep 17 00:00:00 2001
From: Ben Senescu <44480372+bensenescu@users.noreply.github.com>
Date: Fri, 7 Aug 2026 13:47:18 -0400
Subject: [PATCH] feat: add GA4 MCP insights and rank tracking management
(#461)
---
.agents/PAPERCUTS.md | 1 +
CLAUDE.md | 12 +
docs/SELF_HOSTING_GOOGLE_ANALYTICS.md | 79 +
drizzle-pg/0017_ga4_connections.sql | 21 +
drizzle-pg/meta/0017_snapshot.json | 3777 +++++++++++++++++
drizzle-pg/meta/_journal.json | 7 +
drizzle/0039_ga4_connections.sql | 21 +
drizzle/meta/0039_snapshot.json | 3443 +++++++++++++++
drizzle/meta/_journal.json | 7 +
.../0007-google-analytics-mcp-integration.md | 494 +++
src/client/features/ai-mcp/AvailableTools.tsx | 87 +
.../features/dashboard/DashboardPage.tsx | 16 +
.../features/dashboard/Ga4ConnectCard.tsx | 36 +
src/client/features/ga4/Ga4PropertyPicker.tsx | 234 +
.../ga4/GoogleAnalyticsConnectionCard.tsx | 298 ++
.../features/gsc/GscReEngagementModal.tsx | 4 +-
.../gsc/SearchConsoleConnectionCard.tsx | 75 +-
.../features/gsc/SelfHostedSetupWarning.tsx | 22 +-
src/client/features/gsc/SitePicker.tsx | 4 +-
src/client/features/gsc/startGscLink.ts | 37 -
.../integrations/GoogleOAuthSetupWarning.tsx | 28 +
.../integrations/GoogleProductLogos.tsx | 68 +
.../IntegrationConnectionCard.tsx | 75 +
.../features/integrations/startGoogleLink.ts | 52 +
.../SearchConsoleOnboardingStep.tsx | 4 +-
.../features/projects/ProjectSettings.tsx | 6 +
.../RankTrackingDetailHeader.tsx | 4 +-
.../rank-tracking/useSaveConfigMutations.ts | 2 +-
src/db/app.schema.ts | 3 +
src/db/d1/schema.ts | 1 +
src/db/ga4.schema.ts | 41 +
src/db/pg/app.schema.ts | 3 +
src/db/pg/ga4.schema.ts | 37 +
src/db/pg/schema.ts | 1 +
src/db/schema-parity.test.ts | 15 +
src/db/schema.ts | 6 +
src/lib/auth-config.ts | 12 +
src/routeTree.gen.ts | 21 +
src/routes/api/ga4/oauth/callback.ts | 14 +
src/routes/api/gsc/oauth/callback.ts | 44 +-
.../repositories/ActivationRepository.ts | 15 +
.../dashboard/services/DashboardService.ts | 16 +-
.../Ga4ConnectionRepository.test.ts | 95 +
.../repositories/Ga4ConnectionRepository.ts | 84 +
src/server/features/ga4/services/Ga4Dates.ts | 27 +
.../Ga4MeasurementHealthService.test.ts | 91 +
.../services/Ga4MeasurementHealthService.ts | 96 +
.../Ga4OrganicOverviewService.test.ts | 170 +
.../ga4/services/Ga4OrganicOverviewService.ts | 152 +
.../ga4/services/Ga4ReportDefinitions.ts | 296 ++
.../services/Ga4ReportEnhancements.test.ts | 212 +
.../ga4/services/Ga4ReportEnhancements.ts | 360 ++
.../ga4/services/Ga4ReportNormalization.ts | 107 +
.../ga4/services/Ga4ReportingService.test.ts | 392 ++
.../ga4/services/Ga4ReportingService.ts | 377 ++
.../features/ga4/services/Ga4Service.test.ts | 244 ++
.../features/ga4/services/Ga4Service.ts | 179 +
.../services/SearchOpportunityService.test.ts | 238 ++
.../ga4/services/SearchOpportunityService.ts | 293 ++
.../ga4/services/ga4-test-fixtures.ts | 88 +
src/server/features/google/oauth-config.ts | 25 +
.../features/google/selfHostedOAuth.test.ts | 235 +
.../{gsc => google}/selfHostedOAuth.ts | 255 +-
src/server/features/gsc/oauth-config.ts | 29 -
.../features/gsc/services/GscService.test.ts | 46 +-
.../features/gsc/services/GscService.ts | 15 +-
.../repositories/RankTrackingRepository.ts | 53 +-
.../services/RankTrackingKeywordService.ts | 191 +
.../RankTrackingService.management.test.ts | 329 ++
.../services/RankTrackingService.test.ts | 38 +-
.../services/RankTrackingService.ts | 154 +-
.../services/rankCheckRunGuards.test.ts | 113 +
.../services/rankCheckRunGuards.ts | 6 +-
.../services/rankTrackingResults.test.ts | 84 +
.../services/rankTrackingResults.ts | 49 +-
src/server/lib/dataforseo/shared.ts | 3 +-
src/server/lib/ga4Client.test.ts | 412 ++
src/server/lib/ga4Client.ts | 480 +++
src/server/lib/ga4Errors.ts | 59 +
src/server/lib/gscClient.ts | 26 +-
src/server/lib/gscErrors.ts | 27 +
src/server/mcp/instrumentation.test.ts | 55 +-
src/server/mcp/instrumentation.ts | 67 +-
src/server/mcp/server.ts | 314 +-
.../mcp/tools/add-rank-tracking-keywords.ts | 91 +
src/server/mcp/tools/create-project.test.ts | 39 +-
src/server/mcp/tools/create-rank-tracker.ts | 126 +
...taforseo-research-tools.google-ads.test.ts | 37 +-
.../dataforseo-research-tools.market.test.ts | 42 +-
.../tools/dataforseo-research-tools.test.ts | 85 +-
.../mcp/tools/estimate-rank-tracker-cost.ts | 81 +
src/server/mcp/tools/get-rank-tracker.ts | 47 +-
.../mcp/tools/google-analytics-tools.test.ts | 312 ++
.../mcp/tools/google-analytics-tools.ts | 589 +++
.../tools/output-schema-validation.test.ts | 48 +-
.../rank-tracking-management-tools.test.ts | 286 ++
.../tools/remove-rank-tracking-keywords.ts | 69 +
src/server/mcp/tools/run-rank-tracker.ts | 95 +
.../mcp/tools/saved-keywords-tools.test.ts | 40 +-
.../mcp/tools/search-console-tools.test.ts | 97 +-
src/server/mcp/tools/search-console-tools.ts | 15 +-
src/server/mcp/tools/tool-test-support.ts | 50 +
src/server/mcp/tools/tool-text-output.test.ts | 154 +-
.../workflows/RankCheckWorkflow.test.ts | 163 +
src/server/workflows/RankCheckWorkflow.ts | 33 +-
src/serverFunctions/dashboard.ts | 11 +
src/serverFunctions/ga4.ts | 131 +
src/serverFunctions/gsc.ts | 12 +-
src/serverFunctions/rank-tracking.ts | 80 +-
src/shared/ga4.ts | 12 +
src/shared/rank-tracking.test.ts | 50 +-
src/shared/rank-tracking.ts | 60 +-
112 files changed, 17337 insertions(+), 1227 deletions(-)
create mode 100644 docs/SELF_HOSTING_GOOGLE_ANALYTICS.md
create mode 100644 drizzle-pg/0017_ga4_connections.sql
create mode 100644 drizzle-pg/meta/0017_snapshot.json
create mode 100644 drizzle/0039_ga4_connections.sql
create mode 100644 drizzle/meta/0039_snapshot.json
create mode 100644 specs/0007-google-analytics-mcp-integration.md
create mode 100644 src/client/features/dashboard/Ga4ConnectCard.tsx
create mode 100644 src/client/features/ga4/Ga4PropertyPicker.tsx
create mode 100644 src/client/features/ga4/GoogleAnalyticsConnectionCard.tsx
delete mode 100644 src/client/features/gsc/startGscLink.ts
create mode 100644 src/client/features/integrations/GoogleOAuthSetupWarning.tsx
create mode 100644 src/client/features/integrations/GoogleProductLogos.tsx
create mode 100644 src/client/features/integrations/IntegrationConnectionCard.tsx
create mode 100644 src/client/features/integrations/startGoogleLink.ts
create mode 100644 src/db/ga4.schema.ts
create mode 100644 src/db/pg/ga4.schema.ts
create mode 100644 src/routes/api/ga4/oauth/callback.ts
create mode 100644 src/server/features/ga4/repositories/Ga4ConnectionRepository.test.ts
create mode 100644 src/server/features/ga4/repositories/Ga4ConnectionRepository.ts
create mode 100644 src/server/features/ga4/services/Ga4Dates.ts
create mode 100644 src/server/features/ga4/services/Ga4MeasurementHealthService.test.ts
create mode 100644 src/server/features/ga4/services/Ga4MeasurementHealthService.ts
create mode 100644 src/server/features/ga4/services/Ga4OrganicOverviewService.test.ts
create mode 100644 src/server/features/ga4/services/Ga4OrganicOverviewService.ts
create mode 100644 src/server/features/ga4/services/Ga4ReportDefinitions.ts
create mode 100644 src/server/features/ga4/services/Ga4ReportEnhancements.test.ts
create mode 100644 src/server/features/ga4/services/Ga4ReportEnhancements.ts
create mode 100644 src/server/features/ga4/services/Ga4ReportNormalization.ts
create mode 100644 src/server/features/ga4/services/Ga4ReportingService.test.ts
create mode 100644 src/server/features/ga4/services/Ga4ReportingService.ts
create mode 100644 src/server/features/ga4/services/Ga4Service.test.ts
create mode 100644 src/server/features/ga4/services/Ga4Service.ts
create mode 100644 src/server/features/ga4/services/SearchOpportunityService.test.ts
create mode 100644 src/server/features/ga4/services/SearchOpportunityService.ts
create mode 100644 src/server/features/ga4/services/ga4-test-fixtures.ts
create mode 100644 src/server/features/google/oauth-config.ts
create mode 100644 src/server/features/google/selfHostedOAuth.test.ts
rename src/server/features/{gsc => google}/selfHostedOAuth.ts (52%)
delete mode 100644 src/server/features/gsc/oauth-config.ts
create mode 100644 src/server/features/rank-tracking/services/RankTrackingKeywordService.ts
create mode 100644 src/server/features/rank-tracking/services/RankTrackingService.management.test.ts
create mode 100644 src/server/features/rank-tracking/services/rankCheckRunGuards.test.ts
create mode 100644 src/server/features/rank-tracking/services/rankTrackingResults.test.ts
create mode 100644 src/server/lib/ga4Client.test.ts
create mode 100644 src/server/lib/ga4Client.ts
create mode 100644 src/server/lib/ga4Errors.ts
create mode 100644 src/server/lib/gscErrors.ts
create mode 100644 src/server/mcp/tools/add-rank-tracking-keywords.ts
create mode 100644 src/server/mcp/tools/create-rank-tracker.ts
create mode 100644 src/server/mcp/tools/estimate-rank-tracker-cost.ts
create mode 100644 src/server/mcp/tools/google-analytics-tools.test.ts
create mode 100644 src/server/mcp/tools/google-analytics-tools.ts
create mode 100644 src/server/mcp/tools/rank-tracking-management-tools.test.ts
create mode 100644 src/server/mcp/tools/remove-rank-tracking-keywords.ts
create mode 100644 src/server/mcp/tools/run-rank-tracker.ts
create mode 100644 src/server/mcp/tools/tool-test-support.ts
create mode 100644 src/server/workflows/RankCheckWorkflow.test.ts
create mode 100644 src/serverFunctions/ga4.ts
create mode 100644 src/shared/ga4.ts
diff --git a/.agents/PAPERCUTS.md b/.agents/PAPERCUTS.md
index a50c79b..e14c70c 100644
--- a/.agents/PAPERCUTS.md
+++ b/.agents/PAPERCUTS.md
@@ -10,6 +10,7 @@ data, or sensitive paths.
## Open
+- [ ] `2026-08-05T20:59:09Z` — `codex` — The documented `pnpm seed:rank-tracking` command fails before opening local D1 because `scripts/seed-rank-tracking.ts` imports the provider-aware `src/db/schema` barrel and plain `tsx` cannot load the resulting `cloudflare:workers` URL. Keep the seed script on dialect-local schema imports or run it through a Workers-compatible execution path.
- [ ] `2026-08-01T16:28:36Z` — `claude` — web's pinned wrangler 4.71.0 fails `kv namespace create` with a bare "Authentication error [code: 10000]" even though the OAuth token has workers_kv write scope; wrangler@4.118.0 succeeds with identical auth. Fix: bump wrangler in web/package.json.
- [ ] `2026-07-20T20:08:28Z` — `claude` — In a fresh git worktree, `oxlint --type-aware` crashes with `Cannot find module '@oxlint/binding-darwin-arm64'` — the platform-specific optional dep is missing from the worktree's node_modules while tsc/prettier work fine, and plain `pnpm install` reports up-to-date without restoring it; `pnpm install --force` (~22s) fixes it. Worth making the worktree-setup hook (or a documented step) run the forced install so lint doesn't die on fresh worktrees.
- [ ] `2026-07-19T04:06:52Z` — `codex` — `pnpm --dir web build` fails with `vite: command not found` when `web/node_modules` is absent, despite the root toolchain being installed. Document or enforce the package-local install required before validating the `web/` subpackage.
diff --git a/CLAUDE.md b/CLAUDE.md
index 13b145d..14df584 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -12,6 +12,18 @@
- Prefer established project helpers and libraries over hand-rolled implementations.
- Prefer idiomatic TanStack Query, Router, and Form patterns for server state, routing, and submitted forms.
+## Testing
+
+- Don't add tests just for the sake of it. A test exists to enforce core behavior or a hard-to-spot edge case that could actually occur.
+- Keep tests as simple as possible, and always review them looking for simplifications.
+- Test behavior at the public entry point. Assert argument forwarding to a mocked collaborator only when that mapping is the contract (billing params, telemetry events).
+- Statically import the module under test. `vi.mock` is hoisted, so per-test `await import()` and `vi.resetModules()` are banned unless module-level state must reset — comment why.
+- Never re-declare a production class in a test. Import the real one; if the module is too heavy to import, move the class to a leaf module first (see `ga4Errors.ts`, `gscErrors.ts`).
+- `beforeEach` sets default mock return values only. Vitest's `clearMocks` already resets call state — no `mockReset`/`mockClear` ceremonies.
+- Fixtures contain only the fields the test asserts on or the types require. Shared shapes get a factory with overrides (see `ga4-test-fixtures.ts`, `tool-test-support.ts`); a fixture longer than its test's assertions is a smell.
+- One test per invariant. Don't re-test Zod or a library, and don't repeat an output-schema round-trip in every happy path.
+- Don't mock ORM builder chains. Test repositories through services or real SQL evaluation; chain mocks break on refactors that change no behavior.
+
## Log papercuts
When small, non-blocking repository friction occurs—a retried tool call, confusing setup step, flaky command, stale cache, misleading error, or non-obvious gotcha—use the `papercuts` skill and append it to `.agents/PAPERCUTS.md` in the moment. Continue the current task. Real bugs and tracked work are not papercuts, and sensitive data must never be logged.
diff --git a/docs/SELF_HOSTING_GOOGLE_ANALYTICS.md b/docs/SELF_HOSTING_GOOGLE_ANALYTICS.md
new file mode 100644
index 0000000..5ee8391
--- /dev/null
+++ b/docs/SELF_HOSTING_GOOGLE_ANALYTICS.md
@@ -0,0 +1,79 @@
+# Self-hosted Google Analytics
+
+Connecting Google Analytics lets OpenSEO bind a GA4 property to a project. The
+connection is optional and read-only.
+
+## What you'll need
+
+- A Google account with access to the GA4 property.
+- A Google Cloud project with OAuth credentials.
+- `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, and `BETTER_AUTH_SECRET` set on
+ the OpenSEO deployment.
+
+If Search Console is already connected, reuse the same Google Cloud project and
+OAuth client. GA4 still asks for a separate consent grant.
+
+## 1) Enable the Analytics APIs
+
+In the [Google Cloud Console](https://console.cloud.google.com/), enable both:
+
+- [Google Analytics Admin API](https://console.cloud.google.com/apis/library/analyticsadmin.googleapis.com)
+- [Google Analytics Data API](https://console.cloud.google.com/apis/library/analyticsdata.googleapis.com)
+
+The Admin API lists properties during connection. The Data API powers the
+read-only reports added in later GA4 milestones.
+
+## 2) Configure the OAuth consent screen
+
+Under **APIs & Services → OAuth consent screen**, configure the app. While the
+app is in Testing, add every Google account that will connect as a test user.
+
+## 3) Register the callback URL
+
+Open **APIs & Services → Credentials**, edit the Web application OAuth client,
+and add an authorized redirect URI matching the deployment origin plus
+`/api/ga4/oauth/callback`.
+
+| Deployment | Redirect URI |
+| ------------ | -------------------------------------------------------- |
+| Deployed | `https://your-openseo-domain.com/api/ga4/oauth/callback` |
+| Local Docker | `http://localhost:3001/api/ga4/oauth/callback` |
+
+Keep the existing `/api/gsc/oauth/callback` URI if Search Console uses the same
+client.
+
+## 4) Set environment variables
+
+Set these values and restart OpenSEO:
+
+| Variable | Value |
+| ---------------------- | --------------------------------------------------------- |
+| `GOOGLE_CLIENT_ID` | Web application client ID. |
+| `GOOGLE_CLIENT_SECRET` | Web application client secret. |
+| `BETTER_AUTH_SECRET` | Random string of at least 32 characters for token crypto. |
+
+Generate the encryption secret with:
+
+```sh
+openssl rand -base64 32
+```
+
+## 5) Connect a property
+
+Open a project dashboard or **Project settings → Analytics**, click **Connect
+with Google**, approve read-only Analytics access, and choose a GA4 property.
+
+OpenSEO stores the OAuth tokens encrypted in Better Auth's account table. The
+project mapping stores only the selected property metadata and connector
+account. Disconnecting GA4 does not disconnect Search Console.
+
+## Troubleshooting
+
+**`redirect_uri_mismatch`** — make sure the registered URI exactly matches the
+scheme, host, port, and `/api/ga4/oauth/callback` path used by the deployment.
+
+**No properties appear** — confirm that the Analytics Admin API is enabled and
+the connected Google account has access to the property.
+
+**Connection expired** — reconnect the Google account. OAuth apps left in
+Google's Testing status can receive short-lived refresh grants.
diff --git a/drizzle-pg/0017_ga4_connections.sql b/drizzle-pg/0017_ga4_connections.sql
new file mode 100644
index 0000000..8adc9eb
--- /dev/null
+++ b/drizzle-pg/0017_ga4_connections.sql
@@ -0,0 +1,21 @@
+CREATE TABLE "ga4_connections" (
+ "id" text PRIMARY KEY NOT NULL,
+ "project_id" text NOT NULL,
+ "organization_id" text NOT NULL,
+ "property_id" text NOT NULL,
+ "property_display_name" text NOT NULL,
+ "property_time_zone" text NOT NULL,
+ "property_currency_code" text NOT NULL,
+ "connected_by_user_id" text NOT NULL,
+ "ga4_account_id" text NOT NULL,
+ "connected_account_email" text,
+ "created_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL,
+ "updated_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL
+);
+--> statement-breakpoint
+ALTER TABLE "project_activation_state" ADD COLUMN "ga4_card_dismissed_at" text;--> statement-breakpoint
+ALTER TABLE "ga4_connections" ADD CONSTRAINT "ga4_connections_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "ga4_connections" ADD CONSTRAINT "ga4_connections_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+CREATE UNIQUE INDEX "ga4_connections_project_idx" ON "ga4_connections" USING btree ("project_id");--> statement-breakpoint
+CREATE INDEX "ga4_connections_organization_idx" ON "ga4_connections" USING btree ("organization_id");--> statement-breakpoint
+CREATE INDEX "ga4_connections_connector_idx" ON "ga4_connections" USING btree ("connected_by_user_id","ga4_account_id");
\ No newline at end of file
diff --git a/drizzle-pg/meta/0017_snapshot.json b/drizzle-pg/meta/0017_snapshot.json
new file mode 100644
index 0000000..0c86ceb
--- /dev/null
+++ b/drizzle-pg/meta/0017_snapshot.json
@@ -0,0 +1,3777 @@
+{
+ "id": "8ff8511a-6179-415b-8b65-9775d3a0a0b7",
+ "prevId": "29760d3a-c466-45a4-823a-b0ff8b854cee",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.backlink_snapshots": {
+ "name": "backlink_snapshots",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "domain": {
+ "name": "domain",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rank": {
+ "name": "rank",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "backlinks": {
+ "name": "backlinks",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "referring_domains": {
+ "name": "referring_domains",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "broken_backlinks": {
+ "name": "broken_backlinks",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "new_backlinks": {
+ "name": "new_backlinks",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lost_backlinks": {
+ "name": "lost_backlinks",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "new_referring_domains": {
+ "name": "new_referring_domains",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lost_referring_domains": {
+ "name": "lost_referring_domains",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "captured_at": {
+ "name": "captured_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')"
+ }
+ },
+ "indexes": {
+ "backlink_snapshots_project_captured_idx": {
+ "name": "backlink_snapshots_project_captured_idx",
+ "columns": [
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "captured_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "backlink_snapshots_project_id_projects_id_fk": {
+ "name": "backlink_snapshots_project_id_projects_id_fk",
+ "tableFrom": "backlink_snapshots",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.keyword_metrics": {
+ "name": "keyword_metrics",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "keyword": {
+ "name": "keyword",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "location_code": {
+ "name": "location_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "language_code": {
+ "name": "language_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'en'"
+ },
+ "search_volume": {
+ "name": "search_volume",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cpc": {
+ "name": "cpc",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "competition": {
+ "name": "competition",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "keyword_difficulty": {
+ "name": "keyword_difficulty",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "intent": {
+ "name": "intent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "monthly_searches": {
+ "name": "monthly_searches",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "fetched_at": {
+ "name": "fetched_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')"
+ }
+ },
+ "indexes": {
+ "keyword_metrics_unique_project_keyword_location_language": {
+ "name": "keyword_metrics_unique_project_keyword_location_language",
+ "columns": [
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "keyword",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "location_code",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "language_code",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "keyword_metrics_lookup_idx": {
+ "name": "keyword_metrics_lookup_idx",
+ "columns": [
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "keyword",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "location_code",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "language_code",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "fetched_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "keyword_metrics_project_id_projects_id_fk": {
+ "name": "keyword_metrics_project_id_projects_id_fk",
+ "tableFrom": "keyword_metrics",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.organization_activation_state": {
+ "name": "organization_activation_state",
+ "schema": "",
+ "columns": {
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "first_mcp_authorized_at": {
+ "name": "first_mcp_authorized_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "first_mcp_tool_call_at": {
+ "name": "first_mcp_tool_call_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "organization_activation_state_organization_id_organization_id_fk": {
+ "name": "organization_activation_state_organization_id_organization_id_fk",
+ "tableFrom": "organization_activation_state",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.project_activation_state": {
+ "name": "project_activation_state",
+ "schema": "",
+ "columns": {
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "competitor_step_clicked_at": {
+ "name": "competitor_step_clicked_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mcp_card_dismissed_at": {
+ "name": "mcp_card_dismissed_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ga4_card_dismissed_at": {
+ "name": "ga4_card_dismissed_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "project_activation_state_project_id_projects_id_fk": {
+ "name": "project_activation_state_project_id_projects_id_fk",
+ "tableFrom": "project_activation_state",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.projects": {
+ "name": "projects",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "domain": {
+ "name": "domain",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "location_code": {
+ "name": "location_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 2840
+ },
+ "language_code": {
+ "name": "language_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'en'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')"
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "projects_one_default_per_organization_idx": {
+ "name": "projects_one_default_per_organization_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"projects\".\"name\" = 'Default' AND \"projects\".\"domain\" IS NULL AND \"projects\".\"archived_at\" IS NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "projects_organization_id_idx": {
+ "name": "projects_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "projects_organization_id_organization_id_fk": {
+ "name": "projects_organization_id_organization_id_fk",
+ "tableFrom": "projects",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.rank_check_runs": {
+ "name": "rank_check_runs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "config_id": {
+ "name": "config_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "keywords_total": {
+ "name": "keywords_total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "keywords_checked": {
+ "name": "keywords_checked",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "is_subset_run": {
+ "name": "is_subset_run",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')"
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "rank_check_runs_config_idx": {
+ "name": "rank_check_runs_config_idx",
+ "columns": [
+ {
+ "expression": "config_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "rank_check_runs_project_idx": {
+ "name": "rank_check_runs_project_idx",
+ "columns": [
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "rank_check_runs_one_active_per_config_idx": {
+ "name": "rank_check_runs_one_active_per_config_idx",
+ "columns": [
+ {
+ "expression": "config_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"rank_check_runs\".\"status\" IN ('pending', 'running')",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "rank_check_runs_config_id_rank_tracking_configs_id_fk": {
+ "name": "rank_check_runs_config_id_rank_tracking_configs_id_fk",
+ "tableFrom": "rank_check_runs",
+ "tableTo": "rank_tracking_configs",
+ "columnsFrom": [
+ "config_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "rank_check_runs_project_id_projects_id_fk": {
+ "name": "rank_check_runs_project_id_projects_id_fk",
+ "tableFrom": "rank_check_runs",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.rank_snapshots": {
+ "name": "rank_snapshots",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "run_id": {
+ "name": "run_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tracking_keyword_id": {
+ "name": "tracking_keyword_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "keyword": {
+ "name": "keyword",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "device": {
+ "name": "device",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "serp_features": {
+ "name": "serp_features",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "checked_at": {
+ "name": "checked_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')"
+ }
+ },
+ "indexes": {
+ "rank_snapshots_keyword_device_idx": {
+ "name": "rank_snapshots_keyword_device_idx",
+ "columns": [
+ {
+ "expression": "tracking_keyword_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "device",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "checked_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "rank_snapshots_run_keyword_device_idx": {
+ "name": "rank_snapshots_run_keyword_device_idx",
+ "columns": [
+ {
+ "expression": "run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "tracking_keyword_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "device",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "rank_snapshots_run_id_rank_check_runs_id_fk": {
+ "name": "rank_snapshots_run_id_rank_check_runs_id_fk",
+ "tableFrom": "rank_snapshots",
+ "tableTo": "rank_check_runs",
+ "columnsFrom": [
+ "run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.rank_tracking_configs": {
+ "name": "rank_tracking_configs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "domain": {
+ "name": "domain",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "location_code": {
+ "name": "location_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 2840
+ },
+ "language_code": {
+ "name": "language_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'en'"
+ },
+ "devices": {
+ "name": "devices",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'both'"
+ },
+ "serp_depth": {
+ "name": "serp_depth",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "schedule_interval": {
+ "name": "schedule_interval",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'weekly'"
+ },
+ "location_name": {
+ "name": "location_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "last_checked_at": {
+ "name": "last_checked_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "next_check_at": {
+ "name": "next_check_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_skip_reason": {
+ "name": "last_skip_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')"
+ }
+ },
+ "indexes": {
+ "rank_tracking_configs_project_active_created_idx": {
+ "name": "rank_tracking_configs_project_active_created_idx",
+ "columns": [
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "is_active",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "rank_tracking_configs_national_idx": {
+ "name": "rank_tracking_configs_national_idx",
+ "columns": [
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "domain",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "location_code",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"rank_tracking_configs\".\"location_name\" IS NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "rank_tracking_configs_local_idx": {
+ "name": "rank_tracking_configs_local_idx",
+ "columns": [
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "domain",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "location_code",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "location_name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"rank_tracking_configs\".\"location_name\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "rank_tracking_configs_project_id_projects_id_fk": {
+ "name": "rank_tracking_configs_project_id_projects_id_fk",
+ "tableFrom": "rank_tracking_configs",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.rank_tracking_keywords": {
+ "name": "rank_tracking_keywords",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "config_id": {
+ "name": "config_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "keyword": {
+ "name": "keyword",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "search_volume": {
+ "name": "search_volume",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "keyword_difficulty": {
+ "name": "keyword_difficulty",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cpc": {
+ "name": "cpc",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metrics_fetched_at": {
+ "name": "metrics_fetched_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')"
+ }
+ },
+ "indexes": {
+ "rank_tracking_keywords_config_keyword_idx": {
+ "name": "rank_tracking_keywords_config_keyword_idx",
+ "columns": [
+ {
+ "expression": "config_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "keyword",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "rank_tracking_keywords_config_id_rank_tracking_configs_id_fk": {
+ "name": "rank_tracking_keywords_config_id_rank_tracking_configs_id_fk",
+ "tableFrom": "rank_tracking_keywords",
+ "tableTo": "rank_tracking_configs",
+ "columnsFrom": [
+ "config_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.saved_keyword_tag_assignments": {
+ "name": "saved_keyword_tag_assignments",
+ "schema": "",
+ "columns": {
+ "saved_keyword_id": {
+ "name": "saved_keyword_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tag_id": {
+ "name": "tag_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')"
+ }
+ },
+ "indexes": {
+ "saved_keyword_tag_assignments_unique_idx": {
+ "name": "saved_keyword_tag_assignments_unique_idx",
+ "columns": [
+ {
+ "expression": "saved_keyword_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "tag_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "saved_keyword_tag_assignments_tag_idx": {
+ "name": "saved_keyword_tag_assignments_tag_idx",
+ "columns": [
+ {
+ "expression": "tag_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "saved_keyword_tag_assignments_saved_keyword_id_saved_keywords_id_fk": {
+ "name": "saved_keyword_tag_assignments_saved_keyword_id_saved_keywords_id_fk",
+ "tableFrom": "saved_keyword_tag_assignments",
+ "tableTo": "saved_keywords",
+ "columnsFrom": [
+ "saved_keyword_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "saved_keyword_tag_assignments_tag_id_saved_keyword_tags_id_fk": {
+ "name": "saved_keyword_tag_assignments_tag_id_saved_keyword_tags_id_fk",
+ "tableFrom": "saved_keyword_tag_assignments",
+ "tableTo": "saved_keyword_tags",
+ "columnsFrom": [
+ "tag_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.saved_keyword_tags": {
+ "name": "saved_keyword_tags",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "normalized_name": {
+ "name": "normalized_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')"
+ }
+ },
+ "indexes": {
+ "saved_keyword_tags_project_normalized_name_idx": {
+ "name": "saved_keyword_tags_project_normalized_name_idx",
+ "columns": [
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "normalized_name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "saved_keyword_tags_project_name_idx": {
+ "name": "saved_keyword_tags_project_name_idx",
+ "columns": [
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "saved_keyword_tags_project_id_projects_id_fk": {
+ "name": "saved_keyword_tags_project_id_projects_id_fk",
+ "tableFrom": "saved_keyword_tags",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.saved_keywords": {
+ "name": "saved_keywords",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "keyword": {
+ "name": "keyword",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "location_code": {
+ "name": "location_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 2840
+ },
+ "language_code": {
+ "name": "language_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'en'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')"
+ }
+ },
+ "indexes": {
+ "saved_keywords_unique_project_keyword_location_language": {
+ "name": "saved_keywords_unique_project_keyword_location_language",
+ "columns": [
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "keyword",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "location_code",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "language_code",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "saved_keywords_project_created_idx": {
+ "name": "saved_keywords_project_created_idx",
+ "columns": [
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "saved_keywords_project_id_projects_id_fk": {
+ "name": "saved_keywords_project_id_projects_id_fk",
+ "tableFrom": "saved_keywords",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_onboarding_answers": {
+ "name": "user_onboarding_answers",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "interested_features": {
+ "name": "interested_features",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'"
+ },
+ "work_for": {
+ "name": "work_for",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "client_website_count": {
+ "name": "client_website_count",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "found_via": {
+ "name": "found_via",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mcp_setup_intent": {
+ "name": "mcp_setup_intent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gsc_nudge_dismissed_at": {
+ "name": "gsc_nudge_dismissed_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')"
+ }
+ },
+ "indexes": {
+ "user_onboarding_answers_organization_idx": {
+ "name": "user_onboarding_answers_organization_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_onboarding_answers_user_id_user_id_fk": {
+ "name": "user_onboarding_answers_user_id_user_id_fk",
+ "tableFrom": "user_onboarding_answers",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "user_onboarding_answers_organization_id_organization_id_fk": {
+ "name": "user_onboarding_answers_organization_id_organization_id_fk",
+ "tableFrom": "user_onboarding_answers",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.audit_issues": {
+ "name": "audit_issues",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "audit_id": {
+ "name": "audit_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "page_id": {
+ "name": "page_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "page_url": {
+ "name": "page_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_type": {
+ "name": "issue_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "severity": {
+ "name": "severity",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'info'"
+ },
+ "details_json": {
+ "name": "details_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "audit_issues_audit_type_idx": {
+ "name": "audit_issues_audit_type_idx",
+ "columns": [
+ {
+ "expression": "audit_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_issues_page_id_idx": {
+ "name": "audit_issues_page_id_idx",
+ "columns": [
+ {
+ "expression": "page_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "audit_issues_audit_id_audits_id_fk": {
+ "name": "audit_issues_audit_id_audits_id_fk",
+ "tableFrom": "audit_issues",
+ "tableTo": "audits",
+ "columnsFrom": [
+ "audit_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "audit_issues_page_id_audit_pages_id_fk": {
+ "name": "audit_issues_page_id_audit_pages_id_fk",
+ "tableFrom": "audit_issues",
+ "tableTo": "audit_pages",
+ "columnsFrom": [
+ "page_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.audit_lighthouse_results": {
+ "name": "audit_lighthouse_results",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "audit_id": {
+ "name": "audit_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "page_id": {
+ "name": "page_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "strategy": {
+ "name": "strategy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "performance_score": {
+ "name": "performance_score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "accessibility_score": {
+ "name": "accessibility_score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "best_practices_score": {
+ "name": "best_practices_score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "seo_score": {
+ "name": "seo_score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lcp_ms": {
+ "name": "lcp_ms",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cls": {
+ "name": "cls",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "inp_ms": {
+ "name": "inp_ms",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ttfb_ms": {
+ "name": "ttfb_ms",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "payload_size_bytes": {
+ "name": "payload_size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "audit_lighthouse_results_audit_id_idx": {
+ "name": "audit_lighthouse_results_audit_id_idx",
+ "columns": [
+ {
+ "expression": "audit_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_lighthouse_results_page_id_idx": {
+ "name": "audit_lighthouse_results_page_id_idx",
+ "columns": [
+ {
+ "expression": "page_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "audit_lighthouse_results_audit_id_audits_id_fk": {
+ "name": "audit_lighthouse_results_audit_id_audits_id_fk",
+ "tableFrom": "audit_lighthouse_results",
+ "tableTo": "audits",
+ "columnsFrom": [
+ "audit_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "audit_lighthouse_results_page_id_audit_pages_id_fk": {
+ "name": "audit_lighthouse_results_page_id_audit_pages_id_fk",
+ "tableFrom": "audit_lighthouse_results",
+ "tableTo": "audit_pages",
+ "columnsFrom": [
+ "page_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.audit_pages": {
+ "name": "audit_pages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "audit_id": {
+ "name": "audit_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status_code": {
+ "name": "status_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "redirect_url": {
+ "name": "redirect_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "meta_description": {
+ "name": "meta_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "canonical_url": {
+ "name": "canonical_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "robots_meta": {
+ "name": "robots_meta",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "og_title": {
+ "name": "og_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "og_description": {
+ "name": "og_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "og_image": {
+ "name": "og_image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "h1_count": {
+ "name": "h1_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "h2_count": {
+ "name": "h2_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "h3_count": {
+ "name": "h3_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "h4_count": {
+ "name": "h4_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "h5_count": {
+ "name": "h5_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "h6_count": {
+ "name": "h6_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "heading_order_json": {
+ "name": "heading_order_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "word_count": {
+ "name": "word_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "images_total": {
+ "name": "images_total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "images_missing_alt": {
+ "name": "images_missing_alt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "images_json": {
+ "name": "images_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "internal_link_count": {
+ "name": "internal_link_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "external_link_count": {
+ "name": "external_link_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "has_structured_data": {
+ "name": "has_structured_data",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "hreflang_tags_json": {
+ "name": "hreflang_tags_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_indexable": {
+ "name": "is_indexable",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "x_robots_tag": {
+ "name": "x_robots_tag",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "header_canonical_url": {
+ "name": "header_canonical_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "crawl_depth": {
+ "name": "crawl_depth",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "in_sitemap": {
+ "name": "in_sitemap",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "fetch_class": {
+ "name": "fetch_class",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'ok'"
+ },
+ "response_time_ms": {
+ "name": "response_time_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "audit_pages_audit_url_idx": {
+ "name": "audit_pages_audit_url_idx",
+ "columns": [
+ {
+ "expression": "audit_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "url",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "audit_pages_audit_id_audits_id_fk": {
+ "name": "audit_pages_audit_id_audits_id_fk",
+ "tableFrom": "audit_pages",
+ "tableTo": "audits",
+ "columnsFrom": [
+ "audit_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.audits": {
+ "name": "audits",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "started_by_user_id": {
+ "name": "started_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "start_url": {
+ "name": "start_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'running'"
+ },
+ "workflow_instance_id": {
+ "name": "workflow_instance_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "config": {
+ "name": "config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "pages_crawled": {
+ "name": "pages_crawled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "pages_total": {
+ "name": "pages_total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "lighthouse_total": {
+ "name": "lighthouse_total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "lighthouse_completed": {
+ "name": "lighthouse_completed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "lighthouse_failed": {
+ "name": "lighthouse_failed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "current_phase": {
+ "name": "current_phase",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'discovery'"
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_detail": {
+ "name": "error_detail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "failed_phase": {
+ "name": "failed_phase",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')"
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "audits_project_id_idx": {
+ "name": "audits_project_id_idx",
+ "columns": [
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audits_started_by_user_id_idx": {
+ "name": "audits_started_by_user_id_idx",
+ "columns": [
+ {
+ "expression": "started_by_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "audits_project_id_projects_id_fk": {
+ "name": "audits_project_id_projects_id_fk",
+ "tableFrom": "audits",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sam_project_memory": {
+ "name": "sam_project_memory",
+ "schema": "",
+ "columns": {
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "sam_project_memory_project_id_projects_id_fk": {
+ "name": "sam_project_memory_project_id_projects_id_fk",
+ "tableFrom": "sam_project_memory",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "sam_project_memory_project_id_label_pk": {
+ "name": "sam_project_memory_project_id_label_pk",
+ "columns": [
+ "project_id",
+ "label"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sam_sessions": {
+ "name": "sam_sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'New chat'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')"
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "sam_sessions_project_updated_idx": {
+ "name": "sam_sessions_project_updated_idx",
+ "columns": [
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "sam_sessions_project_id_projects_id_fk": {
+ "name": "sam_sessions_project_id_projects_id_fk",
+ "tableFrom": "sam_sessions",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "sam_sessions_user_id_user_id_fk": {
+ "name": "sam_sessions_user_id_user_id_fk",
+ "tableFrom": "sam_sessions",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "account_userId_idx": {
+ "name": "account_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "account_accountId_providerId_idx": {
+ "name": "account_accountId_providerId_idx",
+ "columns": [
+ {
+ "expression": "account_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "provider_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.invitation": {
+ "name": "invitation",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "inviter_id": {
+ "name": "inviter_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "invitation_organizationId_idx": {
+ "name": "invitation_organizationId_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "invitation_email_idx": {
+ "name": "invitation_email_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "invitation_organization_id_organization_id_fk": {
+ "name": "invitation_organization_id_organization_id_fk",
+ "tableFrom": "invitation",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "invitation_inviter_id_user_id_fk": {
+ "name": "invitation_inviter_id_user_id_fk",
+ "tableFrom": "invitation",
+ "tableTo": "user",
+ "columnsFrom": [
+ "inviter_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.member": {
+ "name": "member",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'member'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "member_organizationId_idx": {
+ "name": "member_organizationId_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "member_userId_idx": {
+ "name": "member_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "member_organization_id_organization_id_fk": {
+ "name": "member_organization_id_organization_id_fk",
+ "tableFrom": "member",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "member_user_id_user_id_fk": {
+ "name": "member_user_id_user_id_fk",
+ "tableFrom": "member",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.organization": {
+ "name": "organization",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "logo": {
+ "name": "logo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "organization_slug_uidx": {
+ "name": "organization_slug_uidx",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "organization_slug_unique": {
+ "name": "organization_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "active_organization_id": {
+ "name": "active_organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "session_userId_idx": {
+ "name": "session_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "analytics_opted_out": {
+ "name": "analytics_opted_out",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "verification_expiresAt_idx": {
+ "name": "verification_expiresAt_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.billing_customer_status": {
+ "name": "billing_customer_status",
+ "schema": "",
+ "columns": {
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "is_paying": {
+ "name": "is_paying",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "paid_plan_id": {
+ "name": "paid_plan_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "paid_plan_status": {
+ "name": "paid_plan_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "customer_json": {
+ "name": "customer_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "synced_at": {
+ "name": "synced_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "billing_customer_status_organization_id_organization_id_fk": {
+ "name": "billing_customer_status_organization_id_organization_id_fk",
+ "tableFrom": "billing_customer_status",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.ga4_connections": {
+ "name": "ga4_connections",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "property_id": {
+ "name": "property_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "property_display_name": {
+ "name": "property_display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "property_time_zone": {
+ "name": "property_time_zone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "property_currency_code": {
+ "name": "property_currency_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "connected_by_user_id": {
+ "name": "connected_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ga4_account_id": {
+ "name": "ga4_account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "connected_account_email": {
+ "name": "connected_account_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')"
+ }
+ },
+ "indexes": {
+ "ga4_connections_project_idx": {
+ "name": "ga4_connections_project_idx",
+ "columns": [
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "ga4_connections_organization_idx": {
+ "name": "ga4_connections_organization_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "ga4_connections_connector_idx": {
+ "name": "ga4_connections_connector_idx",
+ "columns": [
+ {
+ "expression": "connected_by_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "ga4_account_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "ga4_connections_project_id_projects_id_fk": {
+ "name": "ga4_connections_project_id_projects_id_fk",
+ "tableFrom": "ga4_connections",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ga4_connections_organization_id_organization_id_fk": {
+ "name": "ga4_connections_organization_id_organization_id_fk",
+ "tableFrom": "ga4_connections",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.gsc_connections": {
+ "name": "gsc_connections",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "site_url": {
+ "name": "site_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "connected_by_user_id": {
+ "name": "connected_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "gsc_account_id": {
+ "name": "gsc_account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "connected_account_email": {
+ "name": "connected_account_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')"
+ }
+ },
+ "indexes": {
+ "gsc_connections_project_idx": {
+ "name": "gsc_connections_project_idx",
+ "columns": [
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "gsc_connections_organization_idx": {
+ "name": "gsc_connections_organization_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "gsc_connections_project_id_projects_id_fk": {
+ "name": "gsc_connections_project_id_projects_id_fk",
+ "tableFrom": "gsc_connections",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "gsc_connections_organization_id_organization_id_fk": {
+ "name": "gsc_connections_organization_id_organization_id_fk",
+ "tableFrom": "gsc_connections",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.reddit_attributions": {
+ "name": "reddit_attributions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "click_id": {
+ "name": "click_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "uuid": {
+ "name": "uuid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "landing_page": {
+ "name": "landing_page",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "referrer": {
+ "name": "referrer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "utm_source": {
+ "name": "utm_source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "utm_medium": {
+ "name": "utm_medium",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "utm_campaign": {
+ "name": "utm_campaign",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "utm_term": {
+ "name": "utm_term",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "utm_content": {
+ "name": "utm_content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "signup_sent_at": {
+ "name": "signup_sent_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "purchase_sent_at": {
+ "name": "purchase_sent_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')"
+ }
+ },
+ "indexes": {
+ "reddit_attributions_user_idx": {
+ "name": "reddit_attributions_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "reddit_attributions_organization_idx": {
+ "name": "reddit_attributions_organization_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "reddit_attributions_user_id_user_id_fk": {
+ "name": "reddit_attributions_user_id_user_id_fk",
+ "tableFrom": "reddit_attributions",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "reddit_attributions_organization_id_organization_id_fk": {
+ "name": "reddit_attributions_organization_id_organization_id_fk",
+ "tableFrom": "reddit_attributions",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.telemetry_state": {
+ "name": "telemetry_state",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "default": 1
+ },
+ "install_id": {
+ "name": "install_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "installed_at": {
+ "name": "installed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_heartbeat_at": {
+ "name": "last_heartbeat_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_version": {
+ "name": "last_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mcp_tool_call_count": {
+ "name": "mcp_tool_call_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle-pg/meta/_journal.json b/drizzle-pg/meta/_journal.json
index bd8ac27..7a37b6a 100644
--- a/drizzle-pg/meta/_journal.json
+++ b/drizzle-pg/meta/_journal.json
@@ -120,6 +120,13 @@
"when": 1785608388841,
"tag": "0016_panoramic_blob",
"breakpoints": true
+ },
+ {
+ "idx": 17,
+ "version": "7",
+ "when": 1786066279773,
+ "tag": "0017_ga4_connections",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/drizzle/0039_ga4_connections.sql b/drizzle/0039_ga4_connections.sql
new file mode 100644
index 0000000..2916bbf
--- /dev/null
+++ b/drizzle/0039_ga4_connections.sql
@@ -0,0 +1,21 @@
+CREATE TABLE `ga4_connections` (
+ `id` text PRIMARY KEY NOT NULL,
+ `project_id` text NOT NULL,
+ `organization_id` text NOT NULL,
+ `property_id` text NOT NULL,
+ `property_display_name` text NOT NULL,
+ `property_time_zone` text NOT NULL,
+ `property_currency_code` text NOT NULL,
+ `connected_by_user_id` text NOT NULL,
+ `ga4_account_id` text NOT NULL,
+ `connected_account_email` text,
+ `created_at` text DEFAULT (current_timestamp) NOT NULL,
+ `updated_at` text DEFAULT (current_timestamp) NOT NULL,
+ FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
+ FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX `ga4_connections_project_idx` ON `ga4_connections` (`project_id`);--> statement-breakpoint
+CREATE INDEX `ga4_connections_organization_idx` ON `ga4_connections` (`organization_id`);--> statement-breakpoint
+CREATE INDEX `ga4_connections_connector_idx` ON `ga4_connections` (`connected_by_user_id`,`ga4_account_id`);--> statement-breakpoint
+ALTER TABLE `project_activation_state` ADD `ga4_card_dismissed_at` text;
\ No newline at end of file
diff --git a/drizzle/meta/0039_snapshot.json b/drizzle/meta/0039_snapshot.json
new file mode 100644
index 0000000..332f965
--- /dev/null
+++ b/drizzle/meta/0039_snapshot.json
@@ -0,0 +1,3443 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "3b73d020-cd95-4304-ade0-04a27d878866",
+ "prevId": "37ac1baa-3415-4cb6-a0b1-d2f658c83497",
+ "tables": {
+ "backlink_snapshots": {
+ "name": "backlink_snapshots",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "domain": {
+ "name": "domain",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rank": {
+ "name": "rank",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "backlinks": {
+ "name": "backlinks",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "referring_domains": {
+ "name": "referring_domains",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "broken_backlinks": {
+ "name": "broken_backlinks",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "new_backlinks": {
+ "name": "new_backlinks",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lost_backlinks": {
+ "name": "lost_backlinks",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "new_referring_domains": {
+ "name": "new_referring_domains",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lost_referring_domains": {
+ "name": "lost_referring_domains",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "captured_at": {
+ "name": "captured_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ }
+ },
+ "indexes": {
+ "backlink_snapshots_project_captured_idx": {
+ "name": "backlink_snapshots_project_captured_idx",
+ "columns": [
+ "project_id",
+ "captured_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "backlink_snapshots_project_id_projects_id_fk": {
+ "name": "backlink_snapshots_project_id_projects_id_fk",
+ "tableFrom": "backlink_snapshots",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "keyword_metrics": {
+ "name": "keyword_metrics",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyword": {
+ "name": "keyword",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "location_code": {
+ "name": "location_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "language_code": {
+ "name": "language_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'en'"
+ },
+ "search_volume": {
+ "name": "search_volume",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cpc": {
+ "name": "cpc",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "competition": {
+ "name": "competition",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "keyword_difficulty": {
+ "name": "keyword_difficulty",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "intent": {
+ "name": "intent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "monthly_searches": {
+ "name": "monthly_searches",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fetched_at": {
+ "name": "fetched_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ }
+ },
+ "indexes": {
+ "keyword_metrics_unique_project_keyword_location_language": {
+ "name": "keyword_metrics_unique_project_keyword_location_language",
+ "columns": [
+ "project_id",
+ "keyword",
+ "location_code",
+ "language_code"
+ ],
+ "isUnique": true
+ },
+ "keyword_metrics_lookup_idx": {
+ "name": "keyword_metrics_lookup_idx",
+ "columns": [
+ "project_id",
+ "keyword",
+ "location_code",
+ "language_code",
+ "fetched_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "keyword_metrics_project_id_projects_id_fk": {
+ "name": "keyword_metrics_project_id_projects_id_fk",
+ "tableFrom": "keyword_metrics",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "organization_activation_state": {
+ "name": "organization_activation_state",
+ "columns": {
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "first_mcp_authorized_at": {
+ "name": "first_mcp_authorized_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "first_mcp_tool_call_at": {
+ "name": "first_mcp_tool_call_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "organization_activation_state_organization_id_organization_id_fk": {
+ "name": "organization_activation_state_organization_id_organization_id_fk",
+ "tableFrom": "organization_activation_state",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "project_activation_state": {
+ "name": "project_activation_state",
+ "columns": {
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "competitor_step_clicked_at": {
+ "name": "competitor_step_clicked_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mcp_card_dismissed_at": {
+ "name": "mcp_card_dismissed_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ga4_card_dismissed_at": {
+ "name": "ga4_card_dismissed_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "project_activation_state_project_id_projects_id_fk": {
+ "name": "project_activation_state_project_id_projects_id_fk",
+ "tableFrom": "project_activation_state",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "projects": {
+ "name": "projects",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "domain": {
+ "name": "domain",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "location_code": {
+ "name": "location_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 2840
+ },
+ "language_code": {
+ "name": "language_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'en'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "projects_one_default_per_organization_idx": {
+ "name": "projects_one_default_per_organization_idx",
+ "columns": [
+ "organization_id"
+ ],
+ "isUnique": true,
+ "where": "\"projects\".\"name\" = 'Default' AND \"projects\".\"domain\" IS NULL AND \"projects\".\"archived_at\" IS NULL"
+ },
+ "projects_organization_id_idx": {
+ "name": "projects_organization_id_idx",
+ "columns": [
+ "organization_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "projects_organization_id_organization_id_fk": {
+ "name": "projects_organization_id_organization_id_fk",
+ "tableFrom": "projects",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rank_check_runs": {
+ "name": "rank_check_runs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "config_id": {
+ "name": "config_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "keywords_total": {
+ "name": "keywords_total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "keywords_checked": {
+ "name": "keywords_checked",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_subset_run": {
+ "name": "is_subset_run",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "rank_check_runs_config_idx": {
+ "name": "rank_check_runs_config_idx",
+ "columns": [
+ "config_id",
+ "started_at"
+ ],
+ "isUnique": false
+ },
+ "rank_check_runs_project_idx": {
+ "name": "rank_check_runs_project_idx",
+ "columns": [
+ "project_id",
+ "started_at"
+ ],
+ "isUnique": false
+ },
+ "rank_check_runs_one_active_per_config_idx": {
+ "name": "rank_check_runs_one_active_per_config_idx",
+ "columns": [
+ "config_id"
+ ],
+ "isUnique": true,
+ "where": "\"rank_check_runs\".\"status\" IN ('pending', 'running')"
+ }
+ },
+ "foreignKeys": {
+ "rank_check_runs_config_id_rank_tracking_configs_id_fk": {
+ "name": "rank_check_runs_config_id_rank_tracking_configs_id_fk",
+ "tableFrom": "rank_check_runs",
+ "tableTo": "rank_tracking_configs",
+ "columnsFrom": [
+ "config_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "rank_check_runs_project_id_projects_id_fk": {
+ "name": "rank_check_runs_project_id_projects_id_fk",
+ "tableFrom": "rank_check_runs",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rank_snapshots": {
+ "name": "rank_snapshots",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "run_id": {
+ "name": "run_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tracking_keyword_id": {
+ "name": "tracking_keyword_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyword": {
+ "name": "keyword",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "device": {
+ "name": "device",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "serp_features": {
+ "name": "serp_features",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "checked_at": {
+ "name": "checked_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ }
+ },
+ "indexes": {
+ "rank_snapshots_keyword_device_idx": {
+ "name": "rank_snapshots_keyword_device_idx",
+ "columns": [
+ "tracking_keyword_id",
+ "device",
+ "checked_at"
+ ],
+ "isUnique": false
+ },
+ "rank_snapshots_run_keyword_device_idx": {
+ "name": "rank_snapshots_run_keyword_device_idx",
+ "columns": [
+ "run_id",
+ "tracking_keyword_id",
+ "device"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "rank_snapshots_run_id_rank_check_runs_id_fk": {
+ "name": "rank_snapshots_run_id_rank_check_runs_id_fk",
+ "tableFrom": "rank_snapshots",
+ "tableTo": "rank_check_runs",
+ "columnsFrom": [
+ "run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rank_tracking_configs": {
+ "name": "rank_tracking_configs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "domain": {
+ "name": "domain",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "location_code": {
+ "name": "location_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 2840
+ },
+ "language_code": {
+ "name": "language_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'en'"
+ },
+ "devices": {
+ "name": "devices",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'both'"
+ },
+ "serp_depth": {
+ "name": "serp_depth",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "schedule_interval": {
+ "name": "schedule_interval",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'weekly'"
+ },
+ "location_name": {
+ "name": "location_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "last_checked_at": {
+ "name": "last_checked_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "next_check_at": {
+ "name": "next_check_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_skip_reason": {
+ "name": "last_skip_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ }
+ },
+ "indexes": {
+ "rank_tracking_configs_project_active_created_idx": {
+ "name": "rank_tracking_configs_project_active_created_idx",
+ "columns": [
+ "project_id",
+ "is_active",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "rank_tracking_configs_national_idx": {
+ "name": "rank_tracking_configs_national_idx",
+ "columns": [
+ "project_id",
+ "domain",
+ "location_code"
+ ],
+ "isUnique": true,
+ "where": "\"rank_tracking_configs\".\"location_name\" IS NULL"
+ },
+ "rank_tracking_configs_local_idx": {
+ "name": "rank_tracking_configs_local_idx",
+ "columns": [
+ "project_id",
+ "domain",
+ "location_code",
+ "location_name"
+ ],
+ "isUnique": true,
+ "where": "\"rank_tracking_configs\".\"location_name\" IS NOT NULL"
+ }
+ },
+ "foreignKeys": {
+ "rank_tracking_configs_project_id_projects_id_fk": {
+ "name": "rank_tracking_configs_project_id_projects_id_fk",
+ "tableFrom": "rank_tracking_configs",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rank_tracking_keywords": {
+ "name": "rank_tracking_keywords",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "config_id": {
+ "name": "config_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyword": {
+ "name": "keyword",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "search_volume": {
+ "name": "search_volume",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "keyword_difficulty": {
+ "name": "keyword_difficulty",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cpc": {
+ "name": "cpc",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metrics_fetched_at": {
+ "name": "metrics_fetched_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ }
+ },
+ "indexes": {
+ "rank_tracking_keywords_config_keyword_idx": {
+ "name": "rank_tracking_keywords_config_keyword_idx",
+ "columns": [
+ "config_id",
+ "keyword"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "rank_tracking_keywords_config_id_rank_tracking_configs_id_fk": {
+ "name": "rank_tracking_keywords_config_id_rank_tracking_configs_id_fk",
+ "tableFrom": "rank_tracking_keywords",
+ "tableTo": "rank_tracking_configs",
+ "columnsFrom": [
+ "config_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "saved_keyword_tag_assignments": {
+ "name": "saved_keyword_tag_assignments",
+ "columns": {
+ "saved_keyword_id": {
+ "name": "saved_keyword_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tag_id": {
+ "name": "tag_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ }
+ },
+ "indexes": {
+ "saved_keyword_tag_assignments_unique_idx": {
+ "name": "saved_keyword_tag_assignments_unique_idx",
+ "columns": [
+ "saved_keyword_id",
+ "tag_id"
+ ],
+ "isUnique": true
+ },
+ "saved_keyword_tag_assignments_tag_idx": {
+ "name": "saved_keyword_tag_assignments_tag_idx",
+ "columns": [
+ "tag_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "saved_keyword_tag_assignments_saved_keyword_id_saved_keywords_id_fk": {
+ "name": "saved_keyword_tag_assignments_saved_keyword_id_saved_keywords_id_fk",
+ "tableFrom": "saved_keyword_tag_assignments",
+ "tableTo": "saved_keywords",
+ "columnsFrom": [
+ "saved_keyword_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "saved_keyword_tag_assignments_tag_id_saved_keyword_tags_id_fk": {
+ "name": "saved_keyword_tag_assignments_tag_id_saved_keyword_tags_id_fk",
+ "tableFrom": "saved_keyword_tag_assignments",
+ "tableTo": "saved_keyword_tags",
+ "columnsFrom": [
+ "tag_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "saved_keyword_tags": {
+ "name": "saved_keyword_tags",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "normalized_name": {
+ "name": "normalized_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ }
+ },
+ "indexes": {
+ "saved_keyword_tags_project_normalized_name_idx": {
+ "name": "saved_keyword_tags_project_normalized_name_idx",
+ "columns": [
+ "project_id",
+ "normalized_name"
+ ],
+ "isUnique": true
+ },
+ "saved_keyword_tags_project_name_idx": {
+ "name": "saved_keyword_tags_project_name_idx",
+ "columns": [
+ "project_id",
+ "name"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "saved_keyword_tags_project_id_projects_id_fk": {
+ "name": "saved_keyword_tags_project_id_projects_id_fk",
+ "tableFrom": "saved_keyword_tags",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "saved_keywords": {
+ "name": "saved_keywords",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyword": {
+ "name": "keyword",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "location_code": {
+ "name": "location_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 2840
+ },
+ "language_code": {
+ "name": "language_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'en'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ }
+ },
+ "indexes": {
+ "saved_keywords_unique_project_keyword_location_language": {
+ "name": "saved_keywords_unique_project_keyword_location_language",
+ "columns": [
+ "project_id",
+ "keyword",
+ "location_code",
+ "language_code"
+ ],
+ "isUnique": true
+ },
+ "saved_keywords_project_created_idx": {
+ "name": "saved_keywords_project_created_idx",
+ "columns": [
+ "project_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "saved_keywords_project_id_projects_id_fk": {
+ "name": "saved_keywords_project_id_projects_id_fk",
+ "tableFrom": "saved_keywords",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user_onboarding_answers": {
+ "name": "user_onboarding_answers",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "interested_features": {
+ "name": "interested_features",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "work_for": {
+ "name": "work_for",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "client_website_count": {
+ "name": "client_website_count",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "found_via": {
+ "name": "found_via",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mcp_setup_intent": {
+ "name": "mcp_setup_intent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "gsc_nudge_dismissed_at": {
+ "name": "gsc_nudge_dismissed_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ }
+ },
+ "indexes": {
+ "user_onboarding_answers_organization_idx": {
+ "name": "user_onboarding_answers_organization_idx",
+ "columns": [
+ "organization_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "user_onboarding_answers_user_id_user_id_fk": {
+ "name": "user_onboarding_answers_user_id_user_id_fk",
+ "tableFrom": "user_onboarding_answers",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "user_onboarding_answers_organization_id_organization_id_fk": {
+ "name": "user_onboarding_answers_organization_id_organization_id_fk",
+ "tableFrom": "user_onboarding_answers",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "audit_issues": {
+ "name": "audit_issues",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "audit_id": {
+ "name": "audit_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "page_id": {
+ "name": "page_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "page_url": {
+ "name": "page_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "issue_type": {
+ "name": "issue_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "severity": {
+ "name": "severity",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'info'"
+ },
+ "details_json": {
+ "name": "details_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "audit_issues_audit_type_idx": {
+ "name": "audit_issues_audit_type_idx",
+ "columns": [
+ "audit_id",
+ "issue_type"
+ ],
+ "isUnique": false
+ },
+ "audit_issues_page_id_idx": {
+ "name": "audit_issues_page_id_idx",
+ "columns": [
+ "page_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "audit_issues_audit_id_audits_id_fk": {
+ "name": "audit_issues_audit_id_audits_id_fk",
+ "tableFrom": "audit_issues",
+ "tableTo": "audits",
+ "columnsFrom": [
+ "audit_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "audit_issues_page_id_audit_pages_id_fk": {
+ "name": "audit_issues_page_id_audit_pages_id_fk",
+ "tableFrom": "audit_issues",
+ "tableTo": "audit_pages",
+ "columnsFrom": [
+ "page_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "audit_lighthouse_results": {
+ "name": "audit_lighthouse_results",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "audit_id": {
+ "name": "audit_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "page_id": {
+ "name": "page_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "strategy": {
+ "name": "strategy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "performance_score": {
+ "name": "performance_score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "accessibility_score": {
+ "name": "accessibility_score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "best_practices_score": {
+ "name": "best_practices_score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "seo_score": {
+ "name": "seo_score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lcp_ms": {
+ "name": "lcp_ms",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cls": {
+ "name": "cls",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inp_ms": {
+ "name": "inp_ms",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ttfb_ms": {
+ "name": "ttfb_ms",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "payload_size_bytes": {
+ "name": "payload_size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "audit_lighthouse_results_audit_id_idx": {
+ "name": "audit_lighthouse_results_audit_id_idx",
+ "columns": [
+ "audit_id"
+ ],
+ "isUnique": false
+ },
+ "audit_lighthouse_results_page_id_idx": {
+ "name": "audit_lighthouse_results_page_id_idx",
+ "columns": [
+ "page_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "audit_lighthouse_results_audit_id_audits_id_fk": {
+ "name": "audit_lighthouse_results_audit_id_audits_id_fk",
+ "tableFrom": "audit_lighthouse_results",
+ "tableTo": "audits",
+ "columnsFrom": [
+ "audit_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "audit_lighthouse_results_page_id_audit_pages_id_fk": {
+ "name": "audit_lighthouse_results_page_id_audit_pages_id_fk",
+ "tableFrom": "audit_lighthouse_results",
+ "tableTo": "audit_pages",
+ "columnsFrom": [
+ "page_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "audit_pages": {
+ "name": "audit_pages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "audit_id": {
+ "name": "audit_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status_code": {
+ "name": "status_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "redirect_url": {
+ "name": "redirect_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "meta_description": {
+ "name": "meta_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "canonical_url": {
+ "name": "canonical_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "robots_meta": {
+ "name": "robots_meta",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "og_title": {
+ "name": "og_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "og_description": {
+ "name": "og_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "og_image": {
+ "name": "og_image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "h1_count": {
+ "name": "h1_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "h2_count": {
+ "name": "h2_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "h3_count": {
+ "name": "h3_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "h4_count": {
+ "name": "h4_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "h5_count": {
+ "name": "h5_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "h6_count": {
+ "name": "h6_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "heading_order_json": {
+ "name": "heading_order_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "word_count": {
+ "name": "word_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "images_total": {
+ "name": "images_total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "images_missing_alt": {
+ "name": "images_missing_alt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "images_json": {
+ "name": "images_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "internal_link_count": {
+ "name": "internal_link_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "external_link_count": {
+ "name": "external_link_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "has_structured_data": {
+ "name": "has_structured_data",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "hreflang_tags_json": {
+ "name": "hreflang_tags_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_indexable": {
+ "name": "is_indexable",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "x_robots_tag": {
+ "name": "x_robots_tag",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "header_canonical_url": {
+ "name": "header_canonical_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "crawl_depth": {
+ "name": "crawl_depth",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "in_sitemap": {
+ "name": "in_sitemap",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fetch_class": {
+ "name": "fetch_class",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'ok'"
+ },
+ "response_time_ms": {
+ "name": "response_time_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "audit_pages_audit_url_idx": {
+ "name": "audit_pages_audit_url_idx",
+ "columns": [
+ "audit_id",
+ "url"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "audit_pages_audit_id_audits_id_fk": {
+ "name": "audit_pages_audit_id_audits_id_fk",
+ "tableFrom": "audit_pages",
+ "tableTo": "audits",
+ "columnsFrom": [
+ "audit_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "audits": {
+ "name": "audits",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "started_by_user_id": {
+ "name": "started_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "start_url": {
+ "name": "start_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'running'"
+ },
+ "workflow_instance_id": {
+ "name": "workflow_instance_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "config": {
+ "name": "config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{}'"
+ },
+ "pages_crawled": {
+ "name": "pages_crawled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "pages_total": {
+ "name": "pages_total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "lighthouse_total": {
+ "name": "lighthouse_total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "lighthouse_completed": {
+ "name": "lighthouse_completed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "lighthouse_failed": {
+ "name": "lighthouse_failed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "current_phase": {
+ "name": "current_phase",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'discovery'"
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error_detail": {
+ "name": "error_detail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "failed_phase": {
+ "name": "failed_phase",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "audits_project_id_idx": {
+ "name": "audits_project_id_idx",
+ "columns": [
+ "project_id"
+ ],
+ "isUnique": false
+ },
+ "audits_started_by_user_id_idx": {
+ "name": "audits_started_by_user_id_idx",
+ "columns": [
+ "started_by_user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "audits_project_id_projects_id_fk": {
+ "name": "audits_project_id_projects_id_fk",
+ "tableFrom": "audits",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sam_project_memory": {
+ "name": "sam_project_memory",
+ "columns": {
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "sam_project_memory_project_id_projects_id_fk": {
+ "name": "sam_project_memory_project_id_projects_id_fk",
+ "tableFrom": "sam_project_memory",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "sam_project_memory_project_id_label_pk": {
+ "columns": [
+ "project_id",
+ "label"
+ ],
+ "name": "sam_project_memory_project_id_label_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sam_sessions": {
+ "name": "sam_sessions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'New chat'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "sam_sessions_project_updated_idx": {
+ "name": "sam_sessions_project_updated_idx",
+ "columns": [
+ "project_id",
+ "updated_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "sam_sessions_project_id_projects_id_fk": {
+ "name": "sam_sessions_project_id_projects_id_fk",
+ "tableFrom": "sam_sessions",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "sam_sessions_user_id_user_id_fk": {
+ "name": "sam_sessions_user_id_user_id_fk",
+ "tableFrom": "sam_sessions",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "account": {
+ "name": "account",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "account_userId_idx": {
+ "name": "account_userId_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ },
+ "account_accountId_providerId_idx": {
+ "name": "account_accountId_providerId_idx",
+ "columns": [
+ "account_id",
+ "provider_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "invitation": {
+ "name": "invitation",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "inviter_id": {
+ "name": "inviter_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "invitation_organizationId_idx": {
+ "name": "invitation_organizationId_idx",
+ "columns": [
+ "organization_id"
+ ],
+ "isUnique": false
+ },
+ "invitation_email_idx": {
+ "name": "invitation_email_idx",
+ "columns": [
+ "email"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "invitation_organization_id_organization_id_fk": {
+ "name": "invitation_organization_id_organization_id_fk",
+ "tableFrom": "invitation",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "invitation_inviter_id_user_id_fk": {
+ "name": "invitation_inviter_id_user_id_fk",
+ "tableFrom": "invitation",
+ "tableTo": "user",
+ "columnsFrom": [
+ "inviter_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "member": {
+ "name": "member",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'member'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "member_organizationId_idx": {
+ "name": "member_organizationId_idx",
+ "columns": [
+ "organization_id"
+ ],
+ "isUnique": false
+ },
+ "member_userId_idx": {
+ "name": "member_userId_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "member_organization_id_organization_id_fk": {
+ "name": "member_organization_id_organization_id_fk",
+ "tableFrom": "member",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "member_user_id_user_id_fk": {
+ "name": "member_user_id_user_id_fk",
+ "tableFrom": "member",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "organization": {
+ "name": "organization",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "logo": {
+ "name": "logo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "organization_slug_unique": {
+ "name": "organization_slug_unique",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": true
+ },
+ "organization_slug_uidx": {
+ "name": "organization_slug_uidx",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "active_organization_id": {
+ "name": "active_organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "session_userId_idx": {
+ "name": "session_userId_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "analytics_opted_out": {
+ "name": "analytics_opted_out",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "verification": {
+ "name": "verification",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ "identifier"
+ ],
+ "isUnique": false
+ },
+ "verification_expiresAt_idx": {
+ "name": "verification_expiresAt_idx",
+ "columns": [
+ "expires_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "billing_customer_status": {
+ "name": "billing_customer_status",
+ "columns": {
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_paying": {
+ "name": "is_paying",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "paid_plan_id": {
+ "name": "paid_plan_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "paid_plan_status": {
+ "name": "paid_plan_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "customer_json": {
+ "name": "customer_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "synced_at": {
+ "name": "synced_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "billing_customer_status_organization_id_organization_id_fk": {
+ "name": "billing_customer_status_organization_id_organization_id_fk",
+ "tableFrom": "billing_customer_status",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "ga4_connections": {
+ "name": "ga4_connections",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "property_id": {
+ "name": "property_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "property_display_name": {
+ "name": "property_display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "property_time_zone": {
+ "name": "property_time_zone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "property_currency_code": {
+ "name": "property_currency_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "connected_by_user_id": {
+ "name": "connected_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ga4_account_id": {
+ "name": "ga4_account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "connected_account_email": {
+ "name": "connected_account_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ }
+ },
+ "indexes": {
+ "ga4_connections_project_idx": {
+ "name": "ga4_connections_project_idx",
+ "columns": [
+ "project_id"
+ ],
+ "isUnique": true
+ },
+ "ga4_connections_organization_idx": {
+ "name": "ga4_connections_organization_idx",
+ "columns": [
+ "organization_id"
+ ],
+ "isUnique": false
+ },
+ "ga4_connections_connector_idx": {
+ "name": "ga4_connections_connector_idx",
+ "columns": [
+ "connected_by_user_id",
+ "ga4_account_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "ga4_connections_project_id_projects_id_fk": {
+ "name": "ga4_connections_project_id_projects_id_fk",
+ "tableFrom": "ga4_connections",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ga4_connections_organization_id_organization_id_fk": {
+ "name": "ga4_connections_organization_id_organization_id_fk",
+ "tableFrom": "ga4_connections",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "gsc_connections": {
+ "name": "gsc_connections",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "site_url": {
+ "name": "site_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "connected_by_user_id": {
+ "name": "connected_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "gsc_account_id": {
+ "name": "gsc_account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "connected_account_email": {
+ "name": "connected_account_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ }
+ },
+ "indexes": {
+ "gsc_connections_project_idx": {
+ "name": "gsc_connections_project_idx",
+ "columns": [
+ "project_id"
+ ],
+ "isUnique": true
+ },
+ "gsc_connections_organization_idx": {
+ "name": "gsc_connections_organization_idx",
+ "columns": [
+ "organization_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "gsc_connections_project_id_projects_id_fk": {
+ "name": "gsc_connections_project_id_projects_id_fk",
+ "tableFrom": "gsc_connections",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "gsc_connections_organization_id_organization_id_fk": {
+ "name": "gsc_connections_organization_id_organization_id_fk",
+ "tableFrom": "gsc_connections",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "reddit_attributions": {
+ "name": "reddit_attributions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "click_id": {
+ "name": "click_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "uuid": {
+ "name": "uuid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "landing_page": {
+ "name": "landing_page",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "referrer": {
+ "name": "referrer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "utm_source": {
+ "name": "utm_source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "utm_medium": {
+ "name": "utm_medium",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "utm_campaign": {
+ "name": "utm_campaign",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "utm_term": {
+ "name": "utm_term",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "utm_content": {
+ "name": "utm_content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signup_sent_at": {
+ "name": "signup_sent_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "purchase_sent_at": {
+ "name": "purchase_sent_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ }
+ },
+ "indexes": {
+ "reddit_attributions_user_idx": {
+ "name": "reddit_attributions_user_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": true
+ },
+ "reddit_attributions_organization_idx": {
+ "name": "reddit_attributions_organization_idx",
+ "columns": [
+ "organization_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "reddit_attributions_user_id_user_id_fk": {
+ "name": "reddit_attributions_user_id_user_id_fk",
+ "tableFrom": "reddit_attributions",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "reddit_attributions_organization_id_organization_id_fk": {
+ "name": "reddit_attributions_organization_id_organization_id_fk",
+ "tableFrom": "reddit_attributions",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "telemetry_state": {
+ "name": "telemetry_state",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "install_id": {
+ "name": "install_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "installed_at": {
+ "name": "installed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_heartbeat_at": {
+ "name": "last_heartbeat_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_version": {
+ "name": "last_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mcp_tool_call_count": {
+ "name": "mcp_tool_call_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index ad07c04..8fd83a5 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -274,6 +274,13 @@
"when": 1784610563184,
"tag": "0038_happy_steel_serpent",
"breakpoints": true
+ },
+ {
+ "idx": 39,
+ "version": "6",
+ "when": 1786066274811,
+ "tag": "0039_ga4_connections",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/specs/0007-google-analytics-mcp-integration.md b/specs/0007-google-analytics-mcp-integration.md
new file mode 100644
index 0000000..e70522d
--- /dev/null
+++ b/specs/0007-google-analytics-mcp-integration.md
@@ -0,0 +1,494 @@
+# Google Analytics MCP integration
+
+## Status
+
+Accepted (2026-08-05) by the OpenSEO maintainer under EVE-33.
+
+Implementation update (2026-08-06): the EVE-33 branch now implements the
+connection lifecycle, dashboard/settings UI, and the four reports specified
+below. Follow-up work on the same branch adds six bounded read-only tools for
+organic overview, traffic acquisition, measurement health, ecommerce, site
+search, and audience breakdowns. The implementation has been verified locally
+but is not shipped until the branch is reviewed, merged, and deployed. The
+remainder of this document preserves the originally accepted decision and
+milestone language.
+
+## Context
+
+OpenSEO can read a project's Google Search Console (GSC) property, but an agent
+cannot see what visitors do after the click. GA4 adds first-party signals such
+as organic sessions, engagement, key events, transactions, and revenue. The
+first release should answer SEO questions without exposing an unrestricted
+analytics report builder.
+
+GA4 and GSC remain separate sources. They use different attribution rules,
+reporting time zones, and definitions, so their counts are not interchangeable.
+The supported join is page-level correlation: search demand and visibility
+from GSC alongside engagement and business value from GA4.
+
+## Maintainer decision
+
+Accept the design proposed in [PR #106](https://github.com/every-app/open-seo/pull/106)
+with these clarifications:
+
+- The GA4 grant and the project-to-property mapping have separate owners and
+ lifecycles.
+- Each MCP tool has a fixed request body, bounded inputs, a discriminated
+ success/error output, and stable privacy and quota metadata.
+- A restricted metric is `null`; an omitted or thresholded row is unknown and
+ is never synthesized as zero.
+- GA4 page joins use a host-and-path key because the `hostName` and
+ `landingPage` dimensions do not provide a URL scheme.
+- GSC dates use `America/Los_Angeles`; GA4 dates use the selected property's
+ IANA time zone. The combined tool reports both.
+- Implementation is divided into backend/service milestones and thin adapter
+ milestones. Merging this document alone does not expose a tool or UI.
+
+PR #106's review found that the key-events report could attribute all-channel
+events to organic traffic. The proposal fixed that finding. This accepted
+contract keeps `Organic Search` as the default and makes any all-channel
+request explicit in both the input and output.
+
+## Decision
+
+The original decision adds a native GA4 connection and four read-only,
+project-scoped MCP tools. The implementation update above records the six
+subsequently approved tools without rewriting the historical contract.
+
+### Authentication and grant ownership
+
+Use a dedicated Better Auth `genericOAuth` provider named `google-analytics`.
+It requests these scopes:
+
+- `openid`, `email`, and `profile` identify the connected Google account.
+- `https://www.googleapis.com/auth/analytics.readonly` discovers properties
+ and reads reports.
+
+Do not add the Analytics scope to `google-search-console`. A separate grant
+keeps GSC access unchanged, allows an agency to use different Google accounts
+for GSC and GA4, and gives GA4 its own reconnect and disconnect lifecycle. No
+Analytics write scope is allowed.
+
+The connecting OpenSEO user owns the Better Auth grant. Better Auth stores its
+OAuth access and refresh tokens, encrypted at rest, in the `account` table
+under the `google-analytics` provider ID. Feature tables must not copy those
+tokens. Refresh-token rotation preserves the existing encrypted refresh token
+when Google omits a new one.
+
+Hosted OpenSEO reuses its Google OAuth client. A self-hosted operator reuses
+`GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, and `BETTER_AUTH_SECRET`, enables
+the Google Analytics Admin API and Google Analytics Data API, and registers
+`/api/ga4/oauth/callback`. GA4 adds no application secret.
+
+### Property mapping ownership
+
+Property discovery paginates Admin API v1beta `accountSummaries.list` and then
+calls `properties.get` for the selected property's time zone and currency.
+Only the Integrations UI can select a property. MCP tools accept a `projectId`;
+they cannot list, select, or change properties.
+
+The `ga4_connections` row belongs to the OpenSEO project and organization, not
+to the connecting user. Any current member who can access the project can read
+through the mapping. The service still executes the Google request through the
+specific connector account that selected the property.
+
+`ga4_connections` has matching SQLite and Postgres definitions:
+
+- `id`, `project_id` (unique), and `organization_id`;
+- `property_id`, stored as the canonical `properties/{id}` resource name;
+- `property_display_name`, `property_time_zone`, and
+ `property_currency_code`;
+- `connected_by_user_id`, `ga4_account_id`, and
+ `connected_account_email`; and
+- created and updated timestamps.
+
+The server function for selection receives `projectId`, `propertyId`, and the
+connector account ID. Project authorization supplies `organizationId` and the
+current user. `Ga4Service` verifies that the current user owns that connector
+grant and that the exact property appears in a fresh discovery response before
+upserting the mapping. Clients cannot submit `organizationId`,
+`connectedByUserId`, account email, time zone, currency, or display name.
+
+Disconnecting always deletes the project's mapping. It deletes the Better Auth
+grant only when the caller owns that grant and no other GA4 connection refers
+to the same `(connected_by_user_id, ga4_account_id)` pair. A different project
+member may remove the project mapping but cannot unlink another user's grant.
+
+### Fixed report inputs
+
+Every tool requires `projectId`. The three GA4-only tools also accept this
+common input:
+
+| Field | Contract |
+| ----------- | ----------------------------------------------- |
+| `startDate` | `YYYY-MM-DD`; must be supplied with `endDate` |
+| `endDate` | `YYYY-MM-DD`; must be supplied with `startDate` |
+| `limit` | Integer from 1 through 1,000; default 100 |
+| `offset` | Non-negative integer; default 0 |
+
+With no explicit dates, the range is the last 28 complete days in the GA4
+property time zone. Explicit ranges are inclusive. The report builder caps the
+end at the last complete property day and moves the start forward when the
+range exceeds 90 days. The response returns requested and resolved dates plus
+`end_date_clamped` or `start_date_clamped` warnings. Invalid date formats,
+reversed dates, and a single date without its pair return `validation_error`
+before an API call.
+
+Only these tool-specific inputs are accepted:
+
+- `get_google_analytics_organic_landing_pages` has no additional report input.
+- `get_google_analytics_page_performance` accepts `includeDate` (boolean,
+ default `false`) and `channel` (`organic_search | all`, default
+ `organic_search`).
+- `get_google_analytics_key_events` accepts `breakdown`
+ (`event | event_and_landing_page`, default `event`) and the same `channel`
+ enum and default.
+- `get_search_opportunities` accepts the shared date pair and `limit` from 1
+ through 100, default 50. It does not expose source offsets or report-builder
+ inputs.
+
+The adapters reject unknown fields. Callers cannot provide property IDs,
+dimensions, metrics, filter expressions, order clauses, currency, time zone,
+or arbitrary GA4 request JSON.
+
+### Fixed reports
+
+The first three tools call Data API v1beta `properties.runReport`. Every
+request sets `keepEmptyRows: false` and `returnPropertyQuota: true`.
+
+| Tool | Fixed request |
+| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `get_google_analytics_organic_landing_pages` | Dimensions `hostName`, `landingPage`; metrics `sessions`, `activeUsers`, `engagedSessions`, `engagementRate`, `keyEvents`, `sessionKeyEventRate`, `transactions`, `purchaseRevenue`; exact `sessionDefaultChannelGroup = Organic Search` filter; order by `sessions` descending |
+| `get_google_analytics_page_performance` | Dimensions `hostName`, `pagePath`, plus `date` only when requested; metrics `screenPageViews`, `activeUsers`, `userEngagementDuration`, `keyEvents`; exact organic channel filter unless `channel = all`; order by `screenPageViews` descending |
+| `get_google_analytics_key_events` | Dimension `eventName`, plus `hostName`, `landingPage` only for the requested breakdown; metrics `keyEvents`, `totalUsers`; exact organic channel filter unless `channel = all`; order by `keyEvents` descending |
+
+The service owns these arrays and builders. `getMetadata` and
+`checkCompatibility` may validate the key-event landing-page combination. An
+unsupported combination returns `ga4_report_incompatible`; it never falls back
+to a custom report.
+
+Properties without ecommerce events return numeric zeros where GA4 returned a
+row with zero ecommerce metrics. If response metadata says the caller's role
+restricts `purchaseRevenue`, the service returns `purchaseRevenue: null` and
+includes the restriction. It does not turn a restricted value into zero.
+
+Realtime, demographic, interest, audience, user-level, custom-dimension, and
+custom-metric inputs are excluded from v1. These reports do not consume OpenSEO
+credits.
+
+### Success output
+
+Each GA4-only tool returns the same envelope with a tool-specific `rows` type:
+
+```text
+{
+ status: "ok",
+ source: {
+ provider: "google_analytics",
+ propertyId,
+ propertyDisplayName
+ },
+ request: {
+ requestedDateRange,
+ resolvedDateRange,
+ propertyTimeZone,
+ currencyCode,
+ channel,
+ limit,
+ offset
+ },
+ rowCount,
+ totalRowCount,
+ rows,
+ pageInfo: { offset, limit, hasMore, nextOffset },
+ reportMetadata: {
+ dataLossFromOtherRow,
+ subjectToThresholding,
+ sampling: [{ samplesReadCount, samplingSpaceSize }],
+ restrictedMetrics: [{ metricName, restrictedMetricTypes }],
+ emptyReason,
+ hasLimitedData
+ },
+ quota: {
+ tokensPerDay,
+ tokensPerHour,
+ concurrentRequests,
+ serverErrorsPerProjectPerHour,
+ potentiallyThresholdedRequestsPerHour,
+ tokensPerProjectPerHour
+ } | null,
+ warnings
+}
+```
+
+`rowCount` is the number of rows in this response. `totalRowCount` is Google's
+validated `rowCount` for the full query before `limit` and `offset`.
+`hasMore` is `offset + rowCount < totalRowCount`; `nextOffset` is
+`offset + rowCount` when `hasMore` is true and `null` otherwise. Each quota
+field is `{ consumed, remaining }` when Google provides it. Quota numbers never
+include OAuth credentials.
+
+`sampling` keeps Google's integer counts as decimal strings. The other metrics
+are parsed to finite numbers after the REST response passes Zod validation.
+`hasLimitedData` is true when thresholding, sampling, an `(other)`-row loss, or
+a metric restriction is present. Thresholding does not prove that a particular
+row is absent, so agent-facing text says the report may be limited. Missing
+rows remain missing.
+
+The MCP adapter validates this output schema and renders text from the same
+object. Structured and text outputs must agree about source, date range,
+channel, row count, limitations, and errors.
+
+### Combined search opportunities
+
+`get_search_opportunities` uses the native GSC connection and the GA4 organic
+landing-page report. It never depends on an optional GA4-to-GSC product link.
+
+The default range is the 28 days ending three days ago. Both sources receive
+the same inclusive date strings. GSC interprets them in
+`America/Los_Angeles`; GA4 interprets them in the selected property's time
+zone. The response includes both zones and a `source_time_zones_differ`
+warning when they differ.
+
+The service considers at most 1,000 rows from each source in v1. GSC returns
+top rows rather than guaranteed complete data. The result therefore includes
+`coverage` with source row counts and `gscRowsTruncated` and
+`ga4RowsTruncated` flags. Agent-facing text must not call a truncated result a
+complete site inventory. `gscRowsTruncated` is true whenever GSC fills its
+1,000-row cap because GSC does not return a total row count;
+`ga4RowsTruncated` is true when GA4's `totalRowCount` exceeds the number of rows
+considered.
+
+The combined success envelope includes `gscTimeZone`, `ga4TimeZone`,
+`coverage`, `rows`, `unmatchedRows`, `warnings`, the full GA4
+`reportMetadata`, and the GA4 `quota` object defined above. If GA4 reports
+thresholding, sampling, other-row loss, or metric restrictions, the combined
+tool preserves the same fields, sets `ga4_data_limited`, and says that an
+unmatched GSC page may have omitted GA4 data. It never interprets an unmatched
+page as having zero sessions, engagement, events, transactions, or revenue.
+
+The join key is normalized host plus path:
+
+1. Lowercase the host and remove a default port.
+2. Ignore the URL scheme, fragment, and query string.
+3. Remove a trailing slash except at the root.
+4. Preserve path case and preserve subdomains. Do not equate `www.example.com`
+ with `example.com`.
+5. Treat `(not set)`, an empty host/path, and invalid GSC URLs as unmatched.
+
+GA4 supplies `hostName` and `landingPage`; GSC supplies a full page URL. The
+response keeps the raw source values and the normalized key. Unparseable rows
+appear in `unmatchedRows` with a stable reason code instead of disappearing.
+
+Candidate pages have GSC impressions and average position from 4 through 20.
+A candidate with no joined GA4 row remains in the output with
+`joinStatus: "gsc_only"`, `ga4: null`, `businessValue: null`, and
+`opportunityScore: null`. The service does not include it in the scoring
+population. If GA4's metadata indicates limited data, every computed score has
+`scoreDataLimited: true`; the score still ranks returned aggregates but cannot
+be used to rank unmatched pages below matched pages.
+
+For candidates with a joined GA4 row, calculate percentile ranks for
+`log1p(impressions)`, `sessionKeyEventRate`, and ranking reachability, where
+position 4 is highest and 20 is lowest. Ties receive the same percentile rank.
+Use this versioned formula:
+
+```text
+opportunityScoreV1 = round(
+ 100 * (0.5 * demand + 0.3 * businessValue + 0.2 * reachability)
+)
+```
+
+If all joined candidate rows report zero key events, substitute
+`engagementRate` for those returned rows and set
+`businessValueFallback: "engagementRate"`. This fallback describes the rows
+returned by GA4; it does not claim that the property has no key events. The
+output contains the components, formula version, raw GSC and GA4 metrics, join
+status, and coverage. The score ranks the joined rows; it is not a forecast.
+
+### Error contract
+
+Services throw typed domain errors. Server-function and MCP adapters map them
+to the same discriminated output:
+
+```text
+{
+ status: "error",
+ error: {
+ code,
+ message,
+ retryable,
+ reconnectUrl?,
+ retryAfterSeconds?,
+ details?
+ }
+}
+```
+
+Stable codes and mappings:
+
+| Code | Cause and adapter behavior |
+| --------------------------- | ------------------------------------------------------------------------------------------------------- |
+| `validation_error` | Zod or report-builder rejection; no Google call |
+| `project_forbidden` | Project authorization failed; no connection details returned |
+| `ga4_not_connected` | Project has no GA4 mapping; return the project Integrations URL |
+| `ga4_reconnect_required` | Token minting failed, `invalid_grant`, or Google returned 401; include a reconnect URL |
+| `ga4_property_inaccessible` | Google returned 403 for the mapped property; keep the mapping and ask a human to reselect or fix access |
+| `ga4_report_incompatible` | Compatibility check or Google 400 rejected a fixed combination; not retryable |
+| `ga4_quota_exhausted` | Google 429 or `RESOURCE_EXHAUSTED`; retryable and include a safe retry delay when available |
+| `ga4_upstream_unavailable` | Google 5xx or network failure; retryable |
+| `ga4_malformed_response` | A 2xx response failed schema or numeric validation; not retryable |
+| `gsc_not_connected` | Combined tool only; project has no GSC mapping |
+| `gsc_reconnect_required` | Combined tool only; the mapped GSC grant cannot mint a token |
+
+A 403 is not treated as proof that the OAuth grant is revoked. Adapters return
+only allow-listed field names, constraints, and Google reason categories in
+`details`; they never pass through a raw upstream body, OAuth credential,
+account identifier, or report filter.
+
+### Privacy, retention, and instrumentation
+
+The service copies these GA4 response metadata fields into the success output:
+`dataLossFromOtherRow`, `samplingMetadatas`, `schemaRestrictionResponse`,
+`emptyReason`, and `subjectToThresholding`. Tests cover each field alone and in
+combination. Agent-facing text states that limited rows are unknown, not zero.
+
+OpenSEO does not persist report rows in v1. A later cache needs an approved
+retention policy and keys scoped to project, property, normalized request, and
+date range. Instrumentation records tool name, project and organization IDs,
+duration, outcome, row count, and quota/error category. It does not log raw
+rows, event names, page paths, filters, property IDs, connected account data,
+or credentials.
+
+## Architecture
+
+Follow the existing application boundary:
+
+```text
+SQLite/Postgres repository -> Ga4Service -> server-function and MCP adapters
+```
+
+- `Ga4ConnectionRepository` owns mapping persistence and dialect parity.
+- A small GA4 REST client owns HTTP, token use, pagination, and Zod validation
+ of Admin and Data API responses.
+- `Ga4Service` owns grant lookup, property verification, typed errors, fixed
+ report builders, date clamps, quota/privacy normalization, URL joins, and
+ opportunity scoring.
+- Project-scoped TanStack server functions own session/project authorization
+ and expose grant status, property listing, selection, and disconnect.
+- MCP handlers own annotations, input/output schemas, response formatting, and
+ registration. They do not build GA4 requests or query repositories.
+
+The Integrations UI and MCP are consumers of the same service rules. Neither
+adapter duplicates property ownership, date, channel, privacy, quota, URL, or
+error logic.
+
+## Implementation milestones
+
+Each milestone is a focused change that can merge after its own tests pass.
+
+### 1. Grant and mapping backend
+
+Add shared provider constants, hosted and self-hosted OAuth paths, SQLite and
+Postgres schemas/migrations, `Ga4ConnectionRepository`, the Admin API client,
+and the connection lifecycle in `Ga4Service`. Verify scope isolation,
+refresh-token preservation, property ownership, reconnect, shared-grant
+disconnect, and dialect parity. This milestone has no MCP tools.
+
+### 2. Fixed-report service
+
+Add the validated Data API client, fixed request builders, typed rows and
+errors, date and row clamps, privacy/quota normalization, and deterministic
+fixtures. Unit tests assert the exact `runReport` body for every allowed input
+variant. This milestone has no server-function or MCP report adapter.
+
+### 3. Opportunity service
+
+Add host/path normalization, native GSC and GA4 orchestration, coverage and
+unmatched-row reporting, the v1 score, tie behavior, and time-zone warnings.
+Tests use synthetic GSC and GA4 fixtures and no live API.
+
+### 4. Server-function and UI adapter
+
+Add project-scoped server functions and the Integrations card for grant,
+property, reconnect, and disconnect states. The functions call `Ga4Service` and
+do not access the repository or Google client directly. This milestone makes
+connection management visible but does not claim that MCP tools exist.
+
+### 5. MCP adapter
+
+Register the four tools with read-only, non-destructive annotations, Zod input
+and output schemas, no-credit behavior, instrumentation, and text/structured
+output agreement. Add authorization and error-mapping tests. The capability is
+shipped only when this milestone and its deployment verification are complete.
+
+## Tests and fixtures
+
+The implementation is incomplete without deterministic tests for:
+
+- exact hosted and self-hosted OAuth URLs, callbacks, scopes, encrypted grant
+ storage, refresh-token preservation, revoked grants, and independent
+ GSC/GA4 accounts;
+- paginated discovery, inaccessible properties, selection through the wrong
+ connector, reconnect, member-initiated mapping removal, and shared-grant
+ disconnect behavior;
+- SQLite/Postgres schema parity and one-property-per-project enforcement;
+- exact report bodies for each tool and allowed variant, including organic
+ filter, order, dates, clamps, limit, offset, `keepEmptyRows: false`, and
+ `returnPropertyQuota: true`;
+- normal, empty, zero-ecommerce, restricted-revenue, thresholded, sampled,
+ other-row-loss, incompatible, 401, 403, 429, 5xx, network, and malformed
+ responses;
+- MCP project authorization, annotations, no-credit behavior, stable error
+ codes, output-schema validation, and text/structured agreement;
+- host/path joins across schemes, query strings, fragments, trailing slashes,
+ default ports, subdomains, `(not set)`, invalid URLs, and case-sensitive
+ paths;
+- score components, ties, no-key-event fallback, source truncation, null scores
+ for GSC-only rows, GA4 limitation propagation, unmatched rows, and differing
+ GSC/GA4 time zones; and
+- UI grant/property states and self-hosted missing-API guidance.
+
+Fixtures are minimal recorded-shape JSON owned by the test suite. Property IDs,
+domains, emails, tokens, event names, and business data use obvious synthetic
+values. Tests never call live Google APIs.
+
+## Non-goals
+
+- GA4 Admin API writes, tag setup, key-event creation, or user access changes.
+- A generic dashboard, arbitrary report JSON, realtime reports, funnels,
+ audiences, cohorts, BigQuery export, advertising reports, or user-level data.
+- Requiring GSC and GA4 to use one Google account or requiring a GA4-to-GSC
+ product link.
+- Claiming that GSC clicks equal GA4 sessions, or treating their dates as one
+ reporting time zone.
+- Historical report storage, scheduled imports, cross-project rollups, or
+ automatic SEO changes based on the score.
+- Presenting this accepted specification as a released integration.
+
+## Consequences
+
+- Existing GSC users must connect Analytics explicitly; no current grant is
+ widened or invalidated.
+- Self-hosted setup adds two API-enable steps and a second callback URL, but no
+ new credential.
+- Fixed reports give agents stable contracts and defer arbitrary analytics
+ questions.
+- The combined tool preserves source provenance and exposes the limits of its
+ join and score.
+- Acceptance authorizes implementation work. It does not advertise GA4 as an
+ available OpenSEO capability.
+
+## References
+
+- [Google Analytics Admin API: `accountSummaries.list`](https://developers.google.com/analytics/devguides/config/admin/v1/rest/v1beta/accountSummaries/list)
+- [Google Analytics Admin API: `properties.get`](https://developers.google.com/analytics/devguides/config/admin/v1/rest/v1beta/properties/get)
+- [Google Analytics Data API: `runReport`](https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/properties/runReport)
+- [Google Analytics Data API: `RunReportResponse`](https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/RunReportResponse)
+- [Google Analytics Data API dimensions and metrics](https://developers.google.com/analytics/devguides/reporting/data/v1/api-schema)
+- [Google Analytics Data API quotas](https://developers.google.com/analytics/devguides/reporting/data/v1/quotas)
+- [Google Analytics Data API: `checkCompatibility`](https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/properties/checkCompatibility)
+- [Google Analytics Data API: `getMetadata`](https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/properties/getMetadata)
+- [Google Analytics Data API response metadata](https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/ResponseMetaData)
+- [Search Console Search Analytics query](https://developers.google.com/webmaster-tools/v1/searchanalytics/query)
+- [OpenSEO GSC integration decision](./0003-google-search-console-integration.md)
diff --git a/src/client/features/ai-mcp/AvailableTools.tsx b/src/client/features/ai-mcp/AvailableTools.tsx
index f33e0ee..31f4cfa 100644
--- a/src/client/features/ai-mcp/AvailableTools.tsx
+++ b/src/client/features/ai-mcp/AvailableTools.tsx
@@ -23,6 +23,31 @@ const toolCategories: ToolCategory[] = [
title: "Get rank tracking positions",
description: "Read tracked keyword positions.",
},
+ {
+ name: "create_rank_tracker",
+ title: "Create a rank tracker",
+ description: "Configure a domain for rank tracking.",
+ },
+ {
+ name: "add_rank_tracking_keywords",
+ title: "Add tracked keywords",
+ description: "Add keywords to an existing rank tracker.",
+ },
+ {
+ name: "remove_rank_tracking_keywords",
+ title: "Remove tracked keywords",
+ description: "Stop tracking selected keyword IDs.",
+ },
+ {
+ name: "estimate_rank_tracker_cost",
+ title: "Estimate rank check cost",
+ description: "Preview the cost of an explicit rank check.",
+ },
+ {
+ name: "run_rank_tracker",
+ title: "Run a rank check",
+ description: "Check a tracker's current positions now.",
+ },
{
name: "get_keyword_metrics",
title: "Get keyword metrics",
@@ -118,6 +143,68 @@ const toolCategories: ToolCategory[] = [
},
],
},
+ {
+ label: "Google Analytics",
+ tools: [
+ {
+ name: "get_google_analytics_organic_overview",
+ title: "Get organic overview",
+ description:
+ "Compare top-line organic performance with the previous period.",
+ },
+ {
+ name: "get_google_analytics_organic_landing_pages",
+ title: "Get organic landing pages",
+ description:
+ "Read organic sessions, engagement, key events, and revenue by landing page.",
+ },
+ {
+ name: "get_google_analytics_page_performance",
+ title: "Get page performance",
+ description: "Read page views, users, engagement time, and key events.",
+ },
+ {
+ name: "get_google_analytics_key_events",
+ title: "Get key events",
+ description: "Read key-event outcomes by event or landing page.",
+ },
+ {
+ name: "get_search_opportunities",
+ title: "Get search opportunities",
+ description:
+ "Join Search Console demand with Analytics outcomes to prioritize pages.",
+ },
+ {
+ name: "get_google_analytics_traffic_acquisition",
+ title: "Get traffic acquisition",
+ description:
+ "Compare channels, source/medium, or campaigns using session outcomes.",
+ },
+ {
+ name: "get_google_analytics_measurement_health",
+ title: "Check measurement health",
+ description:
+ "Inspect streams, enhanced measurement, key events, and custom definitions.",
+ },
+ {
+ name: "get_google_analytics_ecommerce_performance",
+ title: "Get ecommerce performance",
+ description:
+ "Read product-funnel or landing-page transaction performance.",
+ },
+ {
+ name: "get_google_analytics_site_search",
+ title: "Get site search",
+ description: "Read measured internal search terms and outcomes.",
+ },
+ {
+ name: "get_google_analytics_audience_breakdown",
+ title: "Get audience breakdown",
+ description:
+ "Compare device, country, or new-versus-returning audiences.",
+ },
+ ],
+ },
];
export function AvailableTools() {
diff --git a/src/client/features/dashboard/DashboardPage.tsx b/src/client/features/dashboard/DashboardPage.tsx
index fa18ad8..598b0b9 100644
--- a/src/client/features/dashboard/DashboardPage.tsx
+++ b/src/client/features/dashboard/DashboardPage.tsx
@@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { ChevronLeft, ChevronRight, Check } from "lucide-react";
import { captureClientEvent } from "@/client/lib/posthog";
+import { Ga4ConnectCard } from "@/client/features/dashboard/Ga4ConnectCard";
import {
computeNextStep,
isStepDone,
@@ -297,6 +298,7 @@ export function DashboardPage({ projectId }: { projectId: string }) {
const showBacklinks = activation.domain !== null;
const gscConnected = activation.gsc.connected;
+ const ga4Connected = activation.ga4.connected;
return (
@@ -330,6 +332,20 @@ export function DashboardPage({ projectId }: { projectId: string }) {
hasData: gscConnected,
node:
,
},
+ ...(ga4Connected || !activation.ga4.cardDismissedAt
+ ? [
+ {
+ key: "ga4",
+ hasData: ga4Connected,
+ node: (
+
+ ),
+ },
+ ]
+ : []),
{
key: "audit",
hasData: overview?.audit != null,
diff --git a/src/client/features/dashboard/Ga4ConnectCard.tsx b/src/client/features/dashboard/Ga4ConnectCard.tsx
new file mode 100644
index 0000000..3fa6b7d
--- /dev/null
+++ b/src/client/features/dashboard/Ga4ConnectCard.tsx
@@ -0,0 +1,36 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { GoogleAnalyticsConnectionCard } from "@/client/features/ga4/GoogleAnalyticsConnectionCard";
+import { captureClientEvent } from "@/client/lib/posthog";
+import { dismissDashboardGa4Card } from "@/serverFunctions/dashboard";
+
+export function Ga4ConnectCard({
+ projectId,
+ connected,
+}: {
+ projectId: string;
+ connected: boolean;
+}) {
+ const queryClient = useQueryClient();
+ const dismissMutation = useMutation({
+ mutationFn: () => dismissDashboardGa4Card({ data: { projectId } }),
+ onSuccess: () =>
+ void queryClient.invalidateQueries({
+ queryKey: ["dashboardActivation", projectId],
+ }),
+ });
+
+ return (
+
{
+ captureClientEvent("dashboard:ga4_dismiss");
+ dismissMutation.mutate();
+ }
+ }
+ dismissing={dismissMutation.isPending}
+ />
+ );
+}
diff --git a/src/client/features/ga4/Ga4PropertyPicker.tsx b/src/client/features/ga4/Ga4PropertyPicker.tsx
new file mode 100644
index 0000000..e873c44
--- /dev/null
+++ b/src/client/features/ga4/Ga4PropertyPicker.tsx
@@ -0,0 +1,234 @@
+import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
+import { startGoogleLink } from "@/client/features/integrations/startGoogleLink";
+
+type PropertyOption = {
+ propertyId: string;
+ displayName: string;
+ accountDisplayName: string;
+ isSelected: boolean;
+};
+
+type AccountOption = {
+ accountId: string;
+ email: string | null;
+ requiresReconnect: boolean;
+ propertiesUnavailable: boolean;
+ properties: PropertyOption[];
+};
+
+export type Ga4PropertySelection = {
+ accountId: string;
+ propertyId: string;
+};
+
+type SecondaryAction = {
+ label: string;
+ onClick: () => void;
+ destructive?: boolean;
+ disabled?: boolean;
+};
+
+export function Ga4PropertyPicker({
+ loading,
+ error,
+ accounts,
+ selection,
+ onSelect,
+ onSave,
+ saving,
+ onRetry,
+ secondaryAction,
+}: {
+ loading: boolean;
+ error: boolean;
+ accounts: AccountOption[];
+ selection: Ga4PropertySelection | null;
+ onSelect: (selection: Ga4PropertySelection) => void;
+ onSave: () => void;
+ saving: boolean;
+ onRetry: () => void;
+ secondaryAction?: SecondaryAction;
+}) {
+ if (loading) {
+ return (
+
+
+ Loading properties…
+
+ );
+ }
+ if (error) {
+ return (
+
+
+ Couldn’t load your Google Analytics properties.
+
+
+
+ {secondaryAction ? (
+
+ ) : null}
+
+
+ );
+ }
+
+ const allAccountsRequireReconnect =
+ accounts.length > 0 &&
+ accounts.every((account) => account.requiresReconnect);
+ if (allAccountsRequireReconnect) {
+ return (
+
+
+ Connection expired. Reconnect to continue.
+
+
+ void startGoogleLink("ga4", window.location.href)}
+ />
+ {secondaryAction ? (
+
+ ) : null}
+
+
+ );
+ }
+
+ const usableAccounts = accounts.filter(
+ (account) => !account.requiresReconnect && !account.propertiesUnavailable,
+ );
+ const options = usableAccounts.flatMap((account) =>
+ account.properties.map((property) => ({
+ accountId: account.accountId,
+ propertyId: property.propertyId,
+ })),
+ );
+ const selectedIndex = selection
+ ? options.findIndex(
+ (option) =>
+ option.accountId === selection.accountId &&
+ option.propertyId === selection.propertyId,
+ )
+ : -1;
+ const hasUnavailableAccounts = accounts.some(
+ (account) => account.propertiesUnavailable,
+ );
+
+ return (
+
+ {hasUnavailableAccounts ? (
+
+ Some properties couldn’t be loaded. Check that the Analytics
+ Admin API is enabled and that this Google account has property access.
+
+ ) : null}
+
+ {options.length === 0 && !hasUnavailableAccounts ? (
+
+ No Google Analytics properties are available for this account.
+
+ ) : null}
+
+
+
+ {secondaryAction ? (
+
+ ) : null}
+
+
+ );
+}
+
+function SecondaryActionButton({ action }: { action: SecondaryAction }) {
+ return (
+
+ );
+}
+
+function GoogleConnectButton({
+ label,
+ onClick,
+}: {
+ label: string;
+ onClick: () => void;
+}) {
+ return (
+
+ );
+}
diff --git a/src/client/features/ga4/GoogleAnalyticsConnectionCard.tsx b/src/client/features/ga4/GoogleAnalyticsConnectionCard.tsx
new file mode 100644
index 0000000..2c9eb73
--- /dev/null
+++ b/src/client/features/ga4/GoogleAnalyticsConnectionCard.tsx
@@ -0,0 +1,298 @@
+import * as React from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import {
+ Ga4PropertyPicker,
+ type Ga4PropertySelection,
+} from "@/client/features/ga4/Ga4PropertyPicker";
+import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
+import { GoogleOAuthSetupWarning } from "@/client/features/integrations/GoogleOAuthSetupWarning";
+import { IntegrationConnectionCard } from "@/client/features/integrations/IntegrationConnectionCard";
+import { GoogleAnalyticsLogo } from "@/client/features/integrations/GoogleProductLogos";
+import { startGoogleLink } from "@/client/features/integrations/startGoogleLink";
+import { getStandardErrorMessage } from "@/client/lib/error-messages";
+import { captureClientEvent } from "@/client/lib/posthog";
+import { isHostedClientAuthMode } from "@/lib/auth-mode";
+import {
+ disconnectGa4,
+ getGa4Connection,
+ listGa4Properties,
+ setGa4Property,
+} from "@/serverFunctions/ga4";
+import { GA4_SELF_HOSTED_SETUP_DOCS_URL } from "@/shared/ga4";
+
+export function GoogleAnalyticsConnectionCard({
+ projectId,
+ onDismiss,
+ dismissing = false,
+}: {
+ projectId: string;
+ onDismiss?: () => void;
+ dismissing?: boolean;
+}) {
+ const hosted = isHostedClientAuthMode();
+ const queryClient = useQueryClient();
+ const [picking, setPicking] = React.useState(false);
+ const [selection, setSelection] = React.useState(
+ null,
+ );
+ const connectionKey = ["ga4Connection", projectId];
+ const connectionQuery = useQuery({
+ queryKey: connectionKey,
+ queryFn: () => getGa4Connection({ data: { projectId } }),
+ });
+ const connection = connectionQuery.data;
+ const connected = Boolean(connection?.connected);
+ const selfHostedNeedsSetup =
+ !hosted && connectionQuery.isSuccess && !connection?.googleOAuthConfigured;
+ const showPicker = picking || (connection?.currentUserHasGrant && !connected);
+ const propertiesQuery = useQuery({
+ queryKey: ["ga4Properties", projectId],
+ queryFn: () => listGa4Properties({ data: { projectId } }),
+ enabled: Boolean(showPicker && !selfHostedNeedsSetup),
+ });
+ const accounts = React.useMemo(
+ () => propertiesQuery.data?.accounts ?? [],
+ [propertiesQuery.data?.accounts],
+ );
+
+ React.useEffect(() => {
+ if (selection) return;
+ for (const account of accounts) {
+ const selectedProperty = account.properties.find(
+ (property) => property.isSelected,
+ );
+ if (selectedProperty) {
+ setSelection({
+ accountId: account.accountId,
+ propertyId: selectedProperty.propertyId,
+ });
+ return;
+ }
+ }
+ }, [accounts, selection]);
+
+ const invalidateConnectionState = () => {
+ void queryClient.invalidateQueries({ queryKey: connectionKey });
+ void queryClient.invalidateQueries({
+ queryKey: ["dashboardActivation", projectId],
+ });
+ };
+ const setPropertyMutation = useMutation({
+ mutationFn: (selected: Ga4PropertySelection) =>
+ setGa4Property({ data: { projectId, ...selected } }),
+ onSuccess: () => {
+ captureClientEvent("ga4:property_select");
+ toast.success("Google Analytics connected");
+ setPicking(false);
+ invalidateConnectionState();
+ },
+ onError: (error) => toast.error(getStandardErrorMessage(error)),
+ });
+ const disconnectMutation = useMutation({
+ mutationFn: () => disconnectGa4({ data: { projectId } }),
+ onSuccess: () => {
+ toast.success("Google Analytics disconnected");
+ setPicking(false);
+ setSelection(null);
+ invalidateConnectionState();
+ },
+ onError: (error) => toast.error(getStandardErrorMessage(error)),
+ });
+ const handleConnect = () => void startGoogleLink("ga4", window.location.href);
+
+ return (
+ }
+ status={
+ connectionQuery.isLoading
+ ? undefined
+ : selfHostedNeedsSetup
+ ? "setup_required"
+ : connected
+ ? "connected"
+ : "disconnected"
+ }
+ >
+ {connectionQuery.isLoading ? (
+
+
+ Checking…
+
+ ) : selfHostedNeedsSetup ? (
+
+
+ {onDismiss ? (
+
+ ) : null}
+
+ ) : connected && !picking ? (
+ {
+ setSelection(null);
+ setPicking(true);
+ }}
+ onDisconnect={() => disconnectMutation.mutate()}
+ disconnecting={disconnectMutation.isPending}
+ />
+ ) : showPicker ? (
+ selection && setPropertyMutation.mutate(selection)}
+ saving={setPropertyMutation.isPending}
+ onRetry={() => void propertiesQuery.refetch()}
+ secondaryAction={
+ connected
+ ? { label: "Cancel", onClick: () => setPicking(false) }
+ : onDismiss
+ ? {
+ label: "Dismiss",
+ disabled: dismissing,
+ onClick: onDismiss,
+ }
+ : {
+ label: "Disconnect",
+ destructive: true,
+ disabled: disconnectMutation.isPending,
+ onClick: () => disconnectMutation.mutate(),
+ }
+ }
+ />
+ ) : (
+
+
+ Connect GA4 to understand what organic visitors do after they land
+ on your site.
+
+
+
+ {onDismiss ? (
+
+ ) : null}
+
+
+ )}
+
+ );
+}
+
+function DismissButton({
+ onClick,
+ disabled,
+}: {
+ onClick: () => void;
+ disabled: boolean;
+}) {
+ return (
+
+ );
+}
+
+function ConnectedState({
+ displayName,
+ propertyId,
+ timeZone,
+ currencyCode,
+ connectedByEmail,
+ onChange,
+ onDisconnect,
+ disconnecting,
+}: {
+ displayName: string;
+ propertyId: string;
+ timeZone: string;
+ currencyCode: string;
+ connectedByEmail: string | null;
+ onChange: () => void;
+ onDisconnect: () => void;
+ disconnecting: boolean;
+}) {
+ const numericPropertyId = propertyId.replace(/^properties\//, "");
+
+ return (
+
+
+
+
+
+ Selected property
+
+
+ {displayName}
+
+
+
+ ID {numericPropertyId}
+
+
+
+
+
+
- Time zone
+ -
+ {timeZone}
+
+
+
+
- Currency
+ -
+ {currencyCode}
+
+
+ {connectedByEmail ? (
+
+
- Connected account
+ -
+ {connectedByEmail}
+
+
+ ) : null}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/client/features/gsc/GscReEngagementModal.tsx b/src/client/features/gsc/GscReEngagementModal.tsx
index 6146379..ee2c8ea 100644
--- a/src/client/features/gsc/GscReEngagementModal.tsx
+++ b/src/client/features/gsc/GscReEngagementModal.tsx
@@ -2,7 +2,7 @@ import * as React from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Modal } from "@/client/components/Modal";
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
-import { startGscLink } from "@/client/features/gsc/startGscLink";
+import { startGoogleLink } from "@/client/features/integrations/startGoogleLink";
import { onboardingAnswersQueryOptions } from "@/client/features/onboarding/onboardingModel";
import { captureClientEvent } from "@/client/lib/posthog";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
@@ -92,7 +92,7 @@ export function GscReEngagementModal({
const callbackURL = projectId
? `${window.location.origin}/p/${projectId}/settings#search-console`
: window.location.href;
- void startGscLink(callbackURL);
+ void startGoogleLink("gsc", callbackURL);
}
return (
diff --git a/src/client/features/gsc/SearchConsoleConnectionCard.tsx b/src/client/features/gsc/SearchConsoleConnectionCard.tsx
index ae70698..b165fec 100644
--- a/src/client/features/gsc/SearchConsoleConnectionCard.tsx
+++ b/src/client/features/gsc/SearchConsoleConnectionCard.tsx
@@ -5,12 +5,14 @@ import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog";
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
+import { IntegrationConnectionCard } from "@/client/features/integrations/IntegrationConnectionCard";
+import { GoogleSearchConsoleLogo } from "@/client/features/integrations/GoogleProductLogos";
import { SelfHostedSetupWarning } from "@/client/features/gsc/SelfHostedSetupWarning";
import {
SitePicker,
type GscSiteSelection,
} from "@/client/features/gsc/SitePicker";
-import { startGscLink } from "@/client/features/gsc/startGscLink";
+import { startGoogleLink } from "@/client/features/integrations/startGoogleLink";
import {
disconnectGsc,
getGscConnection,
@@ -134,10 +136,12 @@ export function SearchConsoleConnectionCard({
onError: (error) => toast.error(getStandardErrorMessage(error)),
});
- const handleConnect = () => void startGscLink(window.location.href);
+ const handleConnect = () => void startGoogleLink("gsc", window.location.href);
return (
- }
status={
connectionQuery.isLoading
? undefined
@@ -204,68 +208,7 @@ export function SearchConsoleConnectionCard({
)}
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Card shell
-// ---------------------------------------------------------------------------
-
-function IntegrationCard({
- status,
- children,
-}: {
- status?: "connected" | "disconnected" | "setup_required";
- children: React.ReactNode;
-}) {
- return (
-
-
-
- Google Search Console
-
- {status ? : null}
-
-
{children}
-
- );
-}
-
-function StatusPill({
- status,
-}: {
- status: "connected" | "disconnected" | "setup_required";
-}) {
- const connected = status === "connected";
- const setupRequired = status === "setup_required";
- return (
-
-
- {connected
- ? "Connected"
- : setupRequired
- ? "Setup required"
- : "Not connected"}
-
+
);
}
@@ -290,7 +233,7 @@ function ConnectedState({
-
+
{siteUrl}
diff --git a/src/client/features/gsc/SelfHostedSetupWarning.tsx b/src/client/features/gsc/SelfHostedSetupWarning.tsx
index e664120..b9824f5 100644
--- a/src/client/features/gsc/SelfHostedSetupWarning.tsx
+++ b/src/client/features/gsc/SelfHostedSetupWarning.tsx
@@ -1,5 +1,4 @@
-import { AlertTriangle } from "lucide-react";
-import { SafeExternalLink } from "@/client/components/SafeExternalLink";
+import { GoogleOAuthSetupWarning } from "@/client/features/integrations/GoogleOAuthSetupWarning";
import { GSC_SELF_HOSTED_SETUP_DOCS_URL } from "@/shared/gsc";
/**
@@ -8,20 +7,9 @@ import { GSC_SELF_HOSTED_SETUP_DOCS_URL } from "@/shared/gsc";
*/
export function SelfHostedSetupWarning() {
return (
-
-
-
-
Google OAuth client not configured
-
- Add your Google client ID and secret to this OpenSEO deployment before
- connecting Search Console.
-
-
-
-
+
);
}
diff --git a/src/client/features/gsc/SitePicker.tsx b/src/client/features/gsc/SitePicker.tsx
index bd6652f..066aed5 100644
--- a/src/client/features/gsc/SitePicker.tsx
+++ b/src/client/features/gsc/SitePicker.tsx
@@ -1,5 +1,5 @@
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
-import { startGscLink } from "@/client/features/gsc/startGscLink";
+import { startGoogleLink } from "@/client/features/integrations/startGoogleLink";
type SiteOption = {
siteUrl: string;
@@ -177,7 +177,7 @@ export function SitePicker({
diff --git a/src/client/features/gsc/startGscLink.ts b/src/client/features/gsc/startGscLink.ts
deleted file mode 100644
index 71a7b42..0000000
--- a/src/client/features/gsc/startGscLink.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { toast } from "sonner";
-import { getStandardErrorMessage } from "@/client/lib/error-messages";
-import { authClient } from "@/lib/auth-client";
-import { isHostedClientAuthMode } from "@/lib/auth-mode";
-import { startSelfHostedGscLink } from "@/serverFunctions/gsc";
-import { GSC_OAUTH_PROVIDER_ID } from "@/shared/gsc";
-
-/**
- * Kick off the incremental Google Search Console OAuth grant. On success this
- * redirects the whole page to Google's consent screen; `callbackURL` is where
- * Google returns the user afterward. Shared by the connect card, the onboarding
- * step, and the re-engagement nudge so the link/error/redirect flow stays in
- * one place — callers keep their own analytics/dismissal at the call site.
- */
-export async function startGscLink(callbackURL: string): Promise
{
- try {
- if (!isHostedClientAuthMode()) {
- const res = await startSelfHostedGscLink({ data: { callbackURL } });
- window.location.href = res.url;
- return;
- }
-
- const res = await authClient.oauth2.link({
- providerId: GSC_OAUTH_PROVIDER_ID,
- callbackURL,
- });
- if (res.error) {
- toast.error(res.error.message ?? "Could not start Google sign-in");
- return;
- }
- if (res.data?.url) {
- window.location.href = res.data.url;
- }
- } catch (error) {
- toast.error(getStandardErrorMessage(error));
- }
-}
diff --git a/src/client/features/integrations/GoogleOAuthSetupWarning.tsx b/src/client/features/integrations/GoogleOAuthSetupWarning.tsx
new file mode 100644
index 0000000..7c594d6
--- /dev/null
+++ b/src/client/features/integrations/GoogleOAuthSetupWarning.tsx
@@ -0,0 +1,28 @@
+import { AlertTriangle } from "lucide-react";
+import { SafeExternalLink } from "@/client/components/SafeExternalLink";
+
+export function GoogleOAuthSetupWarning({
+ integrationName,
+ docsUrl,
+}: {
+ integrationName: string;
+ docsUrl: string;
+}) {
+ return (
+
+
+
+
Google OAuth client not configured
+
+ Add your Google client ID and secret to this OpenSEO deployment before
+ connecting {integrationName}.
+
+
+
+
+ );
+}
diff --git a/src/client/features/integrations/GoogleProductLogos.tsx b/src/client/features/integrations/GoogleProductLogos.tsx
new file mode 100644
index 0000000..810e420
--- /dev/null
+++ b/src/client/features/integrations/GoogleProductLogos.tsx
@@ -0,0 +1,68 @@
+import type { SVGProps } from "react";
+
+export function GoogleSearchConsoleLogo({
+ className,
+ ...props
+}: SVGProps) {
+ return (
+
+ );
+}
+
+export function GoogleAnalyticsLogo({
+ className,
+ ...props
+}: SVGProps) {
+ return (
+
+ );
+}
diff --git a/src/client/features/integrations/IntegrationConnectionCard.tsx b/src/client/features/integrations/IntegrationConnectionCard.tsx
new file mode 100644
index 0000000..2d71179
--- /dev/null
+++ b/src/client/features/integrations/IntegrationConnectionCard.tsx
@@ -0,0 +1,75 @@
+import type { ReactNode } from "react";
+
+type IntegrationConnectionStatus =
+ | "connected"
+ | "disconnected"
+ | "setup_required";
+
+/** Shared shell for first-party connection cards such as GSC and GA4. */
+export function IntegrationConnectionCard({
+ title,
+ icon,
+ status,
+ children,
+}: {
+ title: string;
+ icon?: ReactNode;
+ status?: IntegrationConnectionStatus;
+ children: ReactNode;
+}) {
+ return (
+
+
+
+ {icon ? (
+
+ {icon}
+
+ ) : null}
+
+ {title}
+
+
+ {status ?
: null}
+
+
{children}
+
+ );
+}
+
+function ConnectionStatusPill({
+ status,
+}: {
+ status: IntegrationConnectionStatus;
+}) {
+ const connected = status === "connected";
+ const setupRequired = status === "setup_required";
+ return (
+
+
+ {connected
+ ? "Connected"
+ : setupRequired
+ ? "Setup required"
+ : "Not connected"}
+
+ );
+}
diff --git a/src/client/features/integrations/startGoogleLink.ts b/src/client/features/integrations/startGoogleLink.ts
new file mode 100644
index 0000000..ff52d4b
--- /dev/null
+++ b/src/client/features/integrations/startGoogleLink.ts
@@ -0,0 +1,52 @@
+import { toast } from "sonner";
+import { getStandardErrorMessage } from "@/client/lib/error-messages";
+import { authClient } from "@/lib/auth-client";
+import { isHostedClientAuthMode } from "@/lib/auth-mode";
+import { startSelfHostedGa4Link } from "@/serverFunctions/ga4";
+import { startSelfHostedGscLink } from "@/serverFunctions/gsc";
+import { GA4_OAUTH_PROVIDER_ID } from "@/shared/ga4";
+import { GSC_OAUTH_PROVIDER_ID } from "@/shared/gsc";
+
+const googleProviders = {
+ gsc: {
+ providerId: GSC_OAUTH_PROVIDER_ID,
+ startSelfHosted: startSelfHostedGscLink,
+ },
+ ga4: {
+ providerId: GA4_OAUTH_PROVIDER_ID,
+ startSelfHosted: startSelfHostedGa4Link,
+ },
+} as const;
+
+/**
+ * Kick off an incremental Google OAuth grant. On success this redirects the
+ * whole page to Google's consent screen; `callbackURL` is where Google returns
+ * the user afterward. Shared by the connection cards, onboarding, property
+ * pickers, and re-engagement prompt so the link/error/redirect flow stays in
+ * one place — callers keep their own analytics and dismissal behavior.
+ */
+export async function startGoogleLink(
+ provider: "gsc" | "ga4",
+ callbackURL: string,
+): Promise {
+ try {
+ const config = googleProviders[provider];
+ if (!isHostedClientAuthMode()) {
+ const res = await config.startSelfHosted({ data: { callbackURL } });
+ window.location.href = res.url;
+ return;
+ }
+
+ const res = await authClient.oauth2.link({
+ providerId: config.providerId,
+ callbackURL,
+ });
+ if (res.error) {
+ toast.error(res.error.message ?? "Could not start Google sign-in");
+ return;
+ }
+ if (res.data?.url) window.location.href = res.data.url;
+ } catch (error) {
+ toast.error(getStandardErrorMessage(error));
+ }
+}
diff --git a/src/client/features/onboarding/SearchConsoleOnboardingStep.tsx b/src/client/features/onboarding/SearchConsoleOnboardingStep.tsx
index 09196b5..5d577a5 100644
--- a/src/client/features/onboarding/SearchConsoleOnboardingStep.tsx
+++ b/src/client/features/onboarding/SearchConsoleOnboardingStep.tsx
@@ -8,7 +8,7 @@ import {
SitePicker,
type GscSiteSelection,
} from "@/client/features/gsc/SitePicker";
-import { startGscLink } from "@/client/features/gsc/startGscLink";
+import { startGoogleLink } from "@/client/features/integrations/startGoogleLink";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog";
import { ProjectMarketFields } from "@/client/features/projects/ProjectMarketFields";
@@ -154,7 +154,7 @@ function GscConnect({ projectId }: { projectId: string }) {
const handleConnect = () => {
captureClientEvent("onboarding:gsc_connect_clicked");
- void startGscLink(window.location.href);
+ void startGoogleLink("gsc", window.location.href);
};
if (connectionQuery.isLoading) return ;
diff --git a/src/client/features/projects/ProjectSettings.tsx b/src/client/features/projects/ProjectSettings.tsx
index 9d3026c..4993142 100644
--- a/src/client/features/projects/ProjectSettings.tsx
+++ b/src/client/features/projects/ProjectSettings.tsx
@@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronLeft } from "lucide-react";
import { toast } from "sonner";
import { SearchConsoleConnectionCard } from "@/client/features/gsc/SearchConsoleConnectionCard";
+import { GoogleAnalyticsConnectionCard } from "@/client/features/ga4/GoogleAnalyticsConnectionCard";
import { ProjectMarketFields } from "@/client/features/projects/ProjectMarketFields";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import {
@@ -61,6 +62,11 @@ export function ProjectSettings({ projectId }: { projectId: string }) {
+
+
1} />
);
diff --git a/src/client/features/rank-tracking/RankTrackingDetailHeader.tsx b/src/client/features/rank-tracking/RankTrackingDetailHeader.tsx
index e8e5434..a1a906f 100644
--- a/src/client/features/rank-tracking/RankTrackingDetailHeader.tsx
+++ b/src/client/features/rank-tracking/RankTrackingDetailHeader.tsx
@@ -31,7 +31,7 @@ export function RankTrackingDetailHeader({
onToggleAddKeywords,
}: {
config: RankTrackingConfig;
- run: { lastCheckedAt: string } | null | undefined;
+ run: { lastCheckedAt: string | null } | null | undefined;
costEstimate: { keywordCount: number; costUsd: number } | undefined;
hasBothDevices: boolean;
activeDevice: "desktop" | "mobile";
@@ -51,7 +51,7 @@ export function RankTrackingDetailHeader({
: (LOCATIONS[config.locationCode] ?? "US")}{" "}
· {devicesLabel(config.devices)} ·{" "}
{scheduleLabel(config.scheduleInterval)}
- {run && (
+ {run?.lastCheckedAt && (
<>
{" "}
· Last: {new Date(run.lastCheckedAt).toLocaleDateString()}
diff --git a/src/client/features/rank-tracking/useSaveConfigMutations.ts b/src/client/features/rank-tracking/useSaveConfigMutations.ts
index b2f5e7b..1fff7e7 100644
--- a/src/client/features/rank-tracking/useSaveConfigMutations.ts
+++ b/src/client/features/rank-tracking/useSaveConfigMutations.ts
@@ -48,7 +48,7 @@ export function useSaveConfigMutations(input: {
onSuccess: (result) => {
captureClientEvent("rank_tracking:config_create");
toast.success("Domain added for rank tracking");
- onCreated(result.configId);
+ onCreated(result.id);
},
onError: (error) => {
toast.error(getStandardErrorMessage(error, "Failed to save config"));
diff --git a/src/db/app.schema.ts b/src/db/app.schema.ts
index 6709dad..e930df5 100644
--- a/src/db/app.schema.ts
+++ b/src/db/app.schema.ts
@@ -381,6 +381,9 @@ export const projectActivationState = sqliteTable("project_activation_state", {
// without faking the org-level first-tool-call milestone, which stays
// truthful and self-heals when a real external call lands.
mcpCardDismissedAt: text("mcp_card_dismissed_at"),
+ // Optional integration pitch: hiding it from the dashboard does not remove
+ // the GA4 connection controls from Project Settings.
+ ga4CardDismissedAt: text("ga4_card_dismissed_at"),
updatedAt: text("updated_at")
.notNull()
.default(sql`(current_timestamp)`),
diff --git a/src/db/d1/schema.ts b/src/db/d1/schema.ts
index 60794b1..0098721 100644
--- a/src/db/d1/schema.ts
+++ b/src/db/d1/schema.ts
@@ -6,6 +6,7 @@ export * from "../audit.schema";
export * from "../sam.schema";
export * from "../better-auth-schema";
export * from "../billing.schema";
+export * from "../ga4.schema";
export * from "../gsc.schema";
export * from "../reddit-attribution.schema";
export * from "../telemetry.schema";
diff --git a/src/db/ga4.schema.ts b/src/db/ga4.schema.ts
new file mode 100644
index 0000000..4af8730
--- /dev/null
+++ b/src/db/ga4.schema.ts
@@ -0,0 +1,41 @@
+import { sql } from "drizzle-orm";
+import { index, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
+import { projects } from "./app.schema";
+import { organization } from "./better-auth-schema";
+
+// Selected Google Analytics property per project. OAuth credentials stay in
+// Better Auth's account table under the dedicated "google-analytics" provider.
+export const ga4Connections = sqliteTable(
+ "ga4_connections",
+ {
+ id: text("id").primaryKey(),
+ projectId: text("project_id")
+ .notNull()
+ .references(() => projects.id, { onDelete: "cascade" }),
+ organizationId: text("organization_id")
+ .notNull()
+ .references(() => organization.id, { onDelete: "cascade" }),
+ // Canonical Admin API resource name, e.g. "properties/123456".
+ propertyId: text("property_id").notNull(),
+ propertyDisplayName: text("property_display_name").notNull(),
+ propertyTimeZone: text("property_time_zone").notNull(),
+ propertyCurrencyCode: text("property_currency_code").notNull(),
+ connectedByUserId: text("connected_by_user_id").notNull(),
+ ga4AccountId: text("ga4_account_id").notNull(),
+ connectedAccountEmail: text("connected_account_email"),
+ createdAt: text("created_at")
+ .notNull()
+ .default(sql`(current_timestamp)`),
+ updatedAt: text("updated_at")
+ .notNull()
+ .default(sql`(current_timestamp)`),
+ },
+ (table) => [
+ uniqueIndex("ga4_connections_project_idx").on(table.projectId),
+ index("ga4_connections_organization_idx").on(table.organizationId),
+ index("ga4_connections_connector_idx").on(
+ table.connectedByUserId,
+ table.ga4AccountId,
+ ),
+ ],
+);
diff --git a/src/db/pg/app.schema.ts b/src/db/pg/app.schema.ts
index 8d62883..0da5271 100644
--- a/src/db/pg/app.schema.ts
+++ b/src/db/pg/app.schema.ts
@@ -373,6 +373,9 @@ export const projectActivationState = pgTable("project_activation_state", {
// without faking the org-level first-tool-call milestone, which stays
// truthful and self-heals when a real external call lands.
mcpCardDismissedAt: timestampColumn("mcp_card_dismissed_at"),
+ // Optional integration pitch: hiding it from the dashboard does not remove
+ // the GA4 connection controls from Project Settings.
+ ga4CardDismissedAt: timestampColumn("ga4_card_dismissed_at"),
updatedAt: timestampColumn("updated_at").notNull().default(isoNow),
});
diff --git a/src/db/pg/ga4.schema.ts b/src/db/pg/ga4.schema.ts
new file mode 100644
index 0000000..52d3dba
--- /dev/null
+++ b/src/db/pg/ga4.schema.ts
@@ -0,0 +1,37 @@
+import { sql } from "drizzle-orm";
+import { index, pgTable, text, uniqueIndex } from "drizzle-orm/pg-core";
+import { projects } from "./app.schema";
+import { organization } from "./better-auth-schema";
+
+const isoNow = sql`to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`;
+
+// Keep this definition structurally identical to ../ga4.schema.ts.
+export const ga4Connections = pgTable(
+ "ga4_connections",
+ {
+ id: text("id").primaryKey(),
+ projectId: text("project_id")
+ .notNull()
+ .references(() => projects.id, { onDelete: "cascade" }),
+ organizationId: text("organization_id")
+ .notNull()
+ .references(() => organization.id, { onDelete: "cascade" }),
+ propertyId: text("property_id").notNull(),
+ propertyDisplayName: text("property_display_name").notNull(),
+ propertyTimeZone: text("property_time_zone").notNull(),
+ propertyCurrencyCode: text("property_currency_code").notNull(),
+ connectedByUserId: text("connected_by_user_id").notNull(),
+ ga4AccountId: text("ga4_account_id").notNull(),
+ connectedAccountEmail: text("connected_account_email"),
+ createdAt: text("created_at").notNull().default(isoNow),
+ updatedAt: text("updated_at").notNull().default(isoNow),
+ },
+ (table) => [
+ uniqueIndex("ga4_connections_project_idx").on(table.projectId),
+ index("ga4_connections_organization_idx").on(table.organizationId),
+ index("ga4_connections_connector_idx").on(
+ table.connectedByUserId,
+ table.ga4AccountId,
+ ),
+ ],
+);
diff --git a/src/db/pg/schema.ts b/src/db/pg/schema.ts
index 65bda45..735b3a5 100644
--- a/src/db/pg/schema.ts
+++ b/src/db/pg/schema.ts
@@ -3,6 +3,7 @@ export * from "./audit.schema";
export * from "./sam.schema";
export * from "./better-auth-schema";
export * from "./billing.schema";
+export * from "./ga4.schema";
export * from "./gsc.schema";
export * from "./reddit-attribution.schema";
export * from "./telemetry.schema";
diff --git a/src/db/schema-parity.test.ts b/src/db/schema-parity.test.ts
index 206a009..4566402 100644
--- a/src/db/schema-parity.test.ts
+++ b/src/db/schema-parity.test.ts
@@ -9,6 +9,7 @@ import * as sqliteAudit from "./audit.schema";
import * as sqliteSam from "./sam.schema";
import * as sqliteAuth from "./better-auth-schema";
import * as sqliteBilling from "./billing.schema";
+import * as sqliteGa4 from "./ga4.schema";
import * as sqliteGsc from "./gsc.schema";
import * as sqliteReddit from "./reddit-attribution.schema";
import * as sqliteTelemetry from "./telemetry.schema";
@@ -17,6 +18,7 @@ import * as pgAudit from "./pg/audit.schema";
import * as pgSam from "./pg/sam.schema";
import * as pgAuth from "./pg/better-auth-schema";
import * as pgBilling from "./pg/billing.schema";
+import * as pgGa4 from "./pg/ga4.schema";
import * as pgGsc from "./pg/gsc.schema";
import * as pgReddit from "./pg/reddit-attribution.schema";
import * as pgTelemetry from "./pg/telemetry.schema";
@@ -135,11 +137,18 @@ function foreignKeys(table: Table, dialect: Dialect): string[] {
);
}
+function checkNames(table: Table, dialect: Dialect): string[] {
+ return sortStrings(
+ getConfig(table, dialect).checks.map((check) => check.name),
+ );
+}
+
const sqliteAppTables = tablesFrom(
sqliteApp,
sqliteAudit,
sqliteSam,
sqliteBilling,
+ sqliteGa4,
sqliteGsc,
sqliteReddit,
sqliteTelemetry,
@@ -149,6 +158,7 @@ const pgAppTables = tablesFrom(
pgAudit,
pgSam,
pgBilling,
+ pgGa4,
pgGsc,
pgReddit,
pgTelemetry,
@@ -189,6 +199,11 @@ describe("schema parity: application tables", () => {
foreignKeys(sqliteTable, "sqlite"),
);
});
+ it("has matching check constraints", () => {
+ expect(checkNames(pgTable, "pg")).toEqual(
+ checkNames(sqliteTable, "sqlite"),
+ );
+ });
});
}
});
diff --git a/src/db/schema.ts b/src/db/schema.ts
index ee445e6..40dd9cf 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -4,6 +4,7 @@ import * as sqliteAudit from "./audit.schema";
import * as sqliteSam from "./sam.schema";
import * as sqliteAuth from "./better-auth-schema";
import * as sqliteBilling from "./billing.schema";
+import * as sqliteGa4 from "./ga4.schema";
import * as sqliteGsc from "./gsc.schema";
import * as sqliteReddit from "./reddit-attribution.schema";
import * as sqliteTelemetry from "./telemetry.schema";
@@ -12,6 +13,7 @@ import * as pgAudit from "./pg/audit.schema";
import * as pgSam from "./pg/sam.schema";
import * as pgAuth from "./pg/better-auth-schema";
import * as pgBilling from "./pg/billing.schema";
+import * as pgGa4 from "./pg/ga4.schema";
import * as pgGsc from "./pg/gsc.schema";
import * as pgReddit from "./pg/reddit-attribution.schema";
import * as pgTelemetry from "./pg/telemetry.schema";
@@ -31,6 +33,7 @@ type AppSchema = typeof sqliteApp &
typeof sqliteSam &
typeof sqliteAuth &
typeof sqliteBilling &
+ typeof sqliteGa4 &
typeof sqliteGsc &
typeof sqliteReddit &
typeof sqliteTelemetry;
@@ -43,6 +46,7 @@ const runtimeSchema =
...pgSam,
...pgAuth,
...pgBilling,
+ ...pgGa4,
...pgGsc,
...pgReddit,
...pgTelemetry,
@@ -53,6 +57,7 @@ const runtimeSchema =
...sqliteSam,
...sqliteAuth,
...sqliteBilling,
+ ...sqliteGa4,
...sqliteGsc,
...sqliteReddit,
...sqliteTelemetry,
@@ -89,6 +94,7 @@ export const {
member,
invitation,
billingCustomerStatus,
+ ga4Connections,
gscConnections,
redditAttributions,
telemetryState,
diff --git a/src/lib/auth-config.ts b/src/lib/auth-config.ts
index 88cd835..50a5fd7 100644
--- a/src/lib/auth-config.ts
+++ b/src/lib/auth-config.ts
@@ -1,6 +1,7 @@
import { env } from "cloudflare:workers";
import { genericOAuth, organization } from "better-auth/plugins";
import { baseAuthOptions } from "@/lib/auth-options";
+import { GA4_OAUTH_PROVIDER_ID, GA4_OAUTH_SCOPES } from "@/shared/ga4";
import { GSC_OAUTH_PROVIDER_ID, GSC_OAUTH_SCOPES } from "@/shared/gsc";
export function createBaseAuthConfig() {
@@ -47,6 +48,17 @@ export function createBaseAuthConfig() {
prompt: "select_account consent",
pkce: true,
},
+ {
+ providerId: GA4_OAUTH_PROVIDER_ID,
+ clientId: env.GOOGLE_CLIENT_ID?.trim() ?? "",
+ clientSecret: env.GOOGLE_CLIENT_SECRET?.trim() ?? "",
+ discoveryUrl:
+ "https://accounts.google.com/.well-known/openid-configuration",
+ scopes: [...GA4_OAUTH_SCOPES],
+ accessType: "offline",
+ prompt: "select_account consent",
+ pkce: true,
+ },
],
}),
],
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index 66db33a..ac7f4e8 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -37,6 +37,7 @@ import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/
import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route'
import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index'
import { Route as ApiGscOauthCallbackRouteImport } from './routes/api/gsc/oauth/callback'
+import { Route as ApiGa4OauthCallbackRouteImport } from './routes/api/ga4/oauth/callback'
import { Route as ProjectPProjectIdSettingsRouteImport } from './routes/_project/p/$projectId/settings'
import { Route as ProjectPProjectIdSearchPerformanceRouteImport } from './routes/_project/p/$projectId/search-performance'
import { Route as ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved'
@@ -193,6 +194,11 @@ const ApiGscOauthCallbackRoute = ApiGscOauthCallbackRouteImport.update({
path: '/api/gsc/oauth/callback',
getParentRoute: () => rootRouteImport,
} as any)
+const ApiGa4OauthCallbackRoute = ApiGa4OauthCallbackRouteImport.update({
+ id: '/api/ga4/oauth/callback',
+ path: '/api/ga4/oauth/callback',
+ getParentRoute: () => rootRouteImport,
+} as any)
const ProjectPProjectIdSettingsRoute =
ProjectPProjectIdSettingsRouteImport.update({
id: '/settings',
@@ -314,6 +320,7 @@ export interface FileRoutesByFullPath {
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
'/p/$projectId/search-performance': typeof ProjectPProjectIdSearchPerformanceRoute
'/p/$projectId/settings': typeof ProjectPProjectIdSettingsRoute
+ '/api/ga4/oauth/callback': typeof ApiGa4OauthCallbackRoute
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
'/p/$projectId/': typeof ProjectPProjectIdIndexRoute
'/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute
@@ -352,6 +359,7 @@ export interface FileRoutesByTo {
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
'/p/$projectId/search-performance': typeof ProjectPProjectIdSearchPerformanceRoute
'/p/$projectId/settings': typeof ProjectPProjectIdSettingsRoute
+ '/api/ga4/oauth/callback': typeof ApiGa4OauthCallbackRoute
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
'/p/$projectId': typeof ProjectPProjectIdIndexRoute
'/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute
@@ -398,6 +406,7 @@ export interface FileRoutesById {
'/_project/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
'/_project/p/$projectId/search-performance': typeof ProjectPProjectIdSearchPerformanceRoute
'/_project/p/$projectId/settings': typeof ProjectPProjectIdSettingsRoute
+ '/api/ga4/oauth/callback': typeof ApiGa4OauthCallbackRoute
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
'/_project/p/$projectId/': typeof ProjectPProjectIdIndexRoute
'/_project/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute
@@ -441,6 +450,7 @@ export interface FileRouteTypes {
| '/p/$projectId/saved'
| '/p/$projectId/search-performance'
| '/p/$projectId/settings'
+ | '/api/ga4/oauth/callback'
| '/api/gsc/oauth/callback'
| '/p/$projectId/'
| '/p/$projectId/rank-tracking/$configId'
@@ -479,6 +489,7 @@ export interface FileRouteTypes {
| '/p/$projectId/saved'
| '/p/$projectId/search-performance'
| '/p/$projectId/settings'
+ | '/api/ga4/oauth/callback'
| '/api/gsc/oauth/callback'
| '/p/$projectId'
| '/p/$projectId/rank-tracking/$configId'
@@ -524,6 +535,7 @@ export interface FileRouteTypes {
| '/_project/p/$projectId/saved'
| '/_project/p/$projectId/search-performance'
| '/_project/p/$projectId/settings'
+ | '/api/ga4/oauth/callback'
| '/api/gsc/oauth/callback'
| '/_project/p/$projectId/'
| '/_project/p/$projectId/rank-tracking/$configId'
@@ -544,6 +556,7 @@ export interface RootRouteChildren {
ApiHealthRoute: typeof ApiHealthRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute
+ ApiGa4OauthCallbackRoute: typeof ApiGa4OauthCallbackRoute
ApiGscOauthCallbackRoute: typeof ApiGscOauthCallbackRoute
}
@@ -745,6 +758,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiGscOauthCallbackRouteImport
parentRoute: typeof rootRouteImport
}
+ '/api/ga4/oauth/callback': {
+ id: '/api/ga4/oauth/callback'
+ path: '/api/ga4/oauth/callback'
+ fullPath: '/api/ga4/oauth/callback'
+ preLoaderRoute: typeof ApiGa4OauthCallbackRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/_project/p/$projectId/settings': {
id: '/_project/p/$projectId/settings'
path: '/settings'
@@ -1007,6 +1027,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiHealthRoute: ApiHealthRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiAutumnSplatRoute: ApiAutumnSplatRoute,
+ ApiGa4OauthCallbackRoute: ApiGa4OauthCallbackRoute,
ApiGscOauthCallbackRoute: ApiGscOauthCallbackRoute,
}
export const routeTree = rootRouteImport
diff --git a/src/routes/api/ga4/oauth/callback.ts b/src/routes/api/ga4/oauth/callback.ts
new file mode 100644
index 0000000..2416c63
--- /dev/null
+++ b/src/routes/api/ga4/oauth/callback.ts
@@ -0,0 +1,14 @@
+import { createFileRoute } from "@tanstack/react-router";
+import {
+ GA4_INTEGRATION,
+ handleSelfHostedGoogleOAuthCallbackRequest,
+} from "@/server/features/google/selfHostedOAuth";
+
+export const Route = createFileRoute("/api/ga4/oauth/callback")({
+ server: {
+ handlers: {
+ GET: async ({ request }: { request: Request }) =>
+ handleSelfHostedGoogleOAuthCallbackRequest(request, GA4_INTEGRATION),
+ },
+ },
+});
diff --git a/src/routes/api/gsc/oauth/callback.ts b/src/routes/api/gsc/oauth/callback.ts
index 2c98b5f..542c069 100644
--- a/src/routes/api/gsc/oauth/callback.ts
+++ b/src/routes/api/gsc/oauth/callback.ts
@@ -1,45 +1,17 @@
import { createFileRoute } from "@tanstack/react-router";
-import { env } from "cloudflare:workers";
-import { getAuthMode, isHostedAuthMode } from "@/lib/auth-mode";
-import { resolveCloudflareAccessContext } from "@/middleware/ensure-user/cloudflareAccess";
-import { resolveLocalNoAuthContext } from "@/middleware/ensure-user/delegated";
-import { responseForAppError } from "@/server/lib/http-errors";
-import { handleSelfHostedGscOAuthCallback } from "@/server/features/gsc/selfHostedOAuth";
-import { getPublicOrigin } from "@/server/mcp/public-origin";
-
-async function resolveSelfHostedContext(request: Request) {
- const authMode = getAuthMode(env.AUTH_MODE);
-
- if (isHostedAuthMode(authMode)) return null;
-
- return authMode === "local_noauth"
- ? resolveLocalNoAuthContext()
- : resolveCloudflareAccessContext(request.headers);
-}
-
-async function handleCallbackRequest(request: Request) {
- try {
- const context = await resolveSelfHostedContext(request);
- if (!context) return new Response("Not found", { status: 404 });
-
- return await handleSelfHostedGscOAuthCallback({
- request,
- user: {
- userId: context.userId,
- userEmail: context.userEmail,
- },
- publicOrigin: getPublicOrigin(request),
- });
- } catch (error) {
- return responseForAppError(error, "Search Console OAuth failed");
- }
-}
+import {
+ GSC_INTEGRATION,
+ handleSelfHostedGoogleOAuthCallbackRequest,
+} from "@/server/features/google/selfHostedOAuth";
export const Route = createFileRoute("/api/gsc/oauth/callback")({
server: {
handlers: {
GET: async ({ request }: { request: Request }) => {
- return handleCallbackRequest(request);
+ return handleSelfHostedGoogleOAuthCallbackRequest(
+ request,
+ GSC_INTEGRATION,
+ );
},
},
},
diff --git a/src/server/features/activation/repositories/ActivationRepository.ts b/src/server/features/activation/repositories/ActivationRepository.ts
index 870b2a9..72fe562 100644
--- a/src/server/features/activation/repositories/ActivationRepository.ts
+++ b/src/server/features/activation/repositories/ActivationRepository.ts
@@ -89,6 +89,20 @@ async function markMcpCardDismissed(projectId: string): Promise
{
});
}
+async function markGa4CardDismissed(projectId: string): Promise {
+ const now = new Date().toISOString();
+ await db
+ .insert(projectActivationState)
+ .values({ projectId, ga4CardDismissedAt: now, updatedAt: now })
+ .onConflictDoUpdate({
+ target: projectActivationState.projectId,
+ set: {
+ ga4CardDismissedAt: sql`coalesce(${projectActivationState.ga4CardDismissedAt}, ${now})`,
+ updatedAt: now,
+ },
+ });
+}
+
export const ActivationRepository = {
getOrganizationActivation,
getProjectActivation,
@@ -96,4 +110,5 @@ export const ActivationRepository = {
recordFirstMcpToolCall,
markCompetitorStepClicked,
markMcpCardDismissed,
+ markGa4CardDismissed,
};
diff --git a/src/server/features/dashboard/services/DashboardService.ts b/src/server/features/dashboard/services/DashboardService.ts
index c83d319..5809f68 100644
--- a/src/server/features/dashboard/services/DashboardService.ts
+++ b/src/server/features/dashboard/services/DashboardService.ts
@@ -3,6 +3,7 @@ import { ActivationRepository } from "@/server/features/activation/repositories/
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
import { getIssueTypePageCountsForAudit } from "@/server/features/audit/repositories/auditSummaryQueries";
import { BacklinkSnapshotRepository } from "@/server/features/dashboard/repositories/BacklinkSnapshotRepository";
+import { Ga4ConnectionRepository } from "@/server/features/ga4/repositories/Ga4ConnectionRepository";
import { GscConnectionRepository } from "@/server/features/gsc/repositories/GscConnectionRepository";
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
import { getLatestResults } from "@/server/features/rank-tracking/services/rankTrackingResults";
@@ -20,6 +21,11 @@ const MAX_CONFIGS_FOR_OVERVIEW = 5;
export type DashboardActivation = {
domain: string | null;
+ ga4: {
+ connected: boolean;
+ propertyDisplayName: string | null;
+ cardDismissedAt: string | null;
+ };
gsc: { connected: boolean; siteUrl: string | null };
mcp: {
authorizedAt: string | null;
@@ -74,7 +80,8 @@ async function getActivation(input: {
organizationId: string;
domain: string | null;
}): Promise {
- const [gsc, orgActivation, projectActivation] = await Promise.all([
+ const [ga4, gsc, orgActivation, projectActivation] = await Promise.all([
+ Ga4ConnectionRepository.getByProjectId(input.projectId),
GscConnectionRepository.getByProjectId(input.projectId),
ActivationRepository.getOrganizationActivation(input.organizationId),
ActivationRepository.getProjectActivation(input.projectId),
@@ -82,6 +89,11 @@ async function getActivation(input: {
return {
domain: input.domain,
+ ga4: {
+ connected: ga4 !== null,
+ propertyDisplayName: ga4?.propertyDisplayName ?? null,
+ cardDismissedAt: projectActivation?.ga4CardDismissedAt ?? null,
+ },
gsc: { connected: gsc !== null, siteUrl: gsc?.siteUrl ?? null },
mcp: {
authorizedAt: orgActivation?.firstMcpAuthorizedAt ?? null,
@@ -127,7 +139,7 @@ async function getRankSummary(
for (const result of results) {
summary.trackedKeywords += result.rows.length;
if (
- result.run &&
+ result.run?.lastCheckedAt &&
(!summary.lastCheckedAt ||
result.run.lastCheckedAt > summary.lastCheckedAt)
) {
diff --git a/src/server/features/ga4/repositories/Ga4ConnectionRepository.test.ts b/src/server/features/ga4/repositories/Ga4ConnectionRepository.test.ts
new file mode 100644
index 0000000..0cdcfbc
--- /dev/null
+++ b/src/server/features/ga4/repositories/Ga4ConnectionRepository.test.ts
@@ -0,0 +1,95 @@
+import { DatabaseSync } from "node:sqlite";
+import type { SQL } from "drizzle-orm";
+import { SQLiteSyncDialect } from "drizzle-orm/sqlite-core";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { Ga4ConnectionRepository } from "./Ga4ConnectionRepository";
+
+const mocks = vi.hoisted(() => ({
+ insert: vi.fn(),
+ connectedAccountEmail: undefined as SQL | undefined,
+}));
+
+vi.mock("cloudflare:workers", () => ({ env: {} }));
+vi.mock("@/db", () => ({ db: { insert: mocks.insert } }));
+
+function evaluateConnectedAccountEmail(expression: SQL): string | null {
+ const database = new DatabaseSync(":memory:");
+ database.exec(`
+ create table ga4_connections (
+ connected_by_user_id text not null,
+ ga4_account_id text not null,
+ connected_account_email text
+ );
+ insert into ga4_connections values ('old-user', 'old-account', 'old@example.com');
+ `);
+ const query = new SQLiteSyncDialect().sqlToQuery(expression);
+ const params = query.params.map((value) => {
+ if (
+ value === null ||
+ typeof value === "string" ||
+ typeof value === "number" ||
+ typeof value === "bigint"
+ ) {
+ return value;
+ }
+ throw new Error("Unexpected SQL parameter type in repository test.");
+ });
+ const row: unknown = database
+ .prepare(`select ${query.sql} as email from ga4_connections`)
+ .get(...params);
+ database.close();
+ if (!row || typeof row !== "object" || !("email" in row)) {
+ throw new Error("Repository test query did not return an email column.");
+ }
+ if (row.email !== null && typeof row.email !== "string") {
+ throw new Error("Repository test query returned an invalid email value.");
+ }
+ return row.email;
+}
+
+describe("Ga4ConnectionRepository", () => {
+ beforeEach(() => {
+ mocks.connectedAccountEmail = undefined;
+ mocks.insert.mockImplementation(() => {
+ const builder = {
+ values: vi.fn(),
+ onConflictDoUpdate: vi.fn(),
+ returning: vi.fn().mockResolvedValue([{ id: "connection-1" }]),
+ };
+ builder.values.mockReturnValue(builder);
+ builder.onConflictDoUpdate.mockImplementation(
+ (input: { set: { connectedAccountEmail: SQL } }) => {
+ mocks.connectedAccountEmail = input.set.connectedAccountEmail;
+ return builder;
+ },
+ );
+ return builder;
+ });
+ });
+
+ it.each([
+ ["old-user", "old-account", "old@example.com"],
+ ["new-user", "old-account", null],
+ ["old-user", "new-account", null],
+ ] as const)(
+ "preserves a missing email only for the same user and account",
+ async (connectedByUserId, ga4AccountId, expectedEmail) => {
+ await Ga4ConnectionRepository.upsert({
+ projectId: "project-1",
+ organizationId: "organization-1",
+ propertyId: "properties/11",
+ propertyDisplayName: "Site",
+ propertyTimeZone: "America/New_York",
+ propertyCurrencyCode: "USD",
+ connectedByUserId,
+ ga4AccountId,
+ connectedAccountEmail: null,
+ });
+
+ expect(mocks.connectedAccountEmail).toBeDefined();
+ expect(evaluateConnectedAccountEmail(mocks.connectedAccountEmail!)).toBe(
+ expectedEmail,
+ );
+ },
+ );
+});
diff --git a/src/server/features/ga4/repositories/Ga4ConnectionRepository.ts b/src/server/features/ga4/repositories/Ga4ConnectionRepository.ts
new file mode 100644
index 0000000..097a24f
--- /dev/null
+++ b/src/server/features/ga4/repositories/Ga4ConnectionRepository.ts
@@ -0,0 +1,84 @@
+import { and, eq, sql } from "drizzle-orm";
+import { db } from "@/db";
+import { ga4Connections } from "@/db/schema";
+
+export type Ga4Connection = typeof ga4Connections.$inferSelect;
+
+async function getByProjectId(
+ projectId: string,
+): Promise {
+ const rows = await db
+ .select()
+ .from(ga4Connections)
+ .where(eq(ga4Connections.projectId, projectId))
+ .limit(1);
+ return rows[0] ?? null;
+}
+
+async function upsert(input: {
+ projectId: string;
+ organizationId: string;
+ propertyId: string;
+ propertyDisplayName: string;
+ propertyTimeZone: string;
+ propertyCurrencyCode: string;
+ connectedByUserId: string;
+ ga4AccountId: string;
+ connectedAccountEmail: string | null;
+}): Promise {
+ const [row] = await db
+ .insert(ga4Connections)
+ .values({ id: crypto.randomUUID(), ...input })
+ .onConflictDoUpdate({
+ target: ga4Connections.projectId,
+ set: {
+ organizationId: input.organizationId,
+ propertyId: input.propertyId,
+ propertyDisplayName: input.propertyDisplayName,
+ propertyTimeZone: input.propertyTimeZone,
+ propertyCurrencyCode: input.propertyCurrencyCode,
+ connectedByUserId: input.connectedByUserId,
+ ga4AccountId: input.ga4AccountId,
+ connectedAccountEmail: sql`case
+ when ${ga4Connections.connectedByUserId} = ${input.connectedByUserId}
+ and ${ga4Connections.ga4AccountId} = ${input.ga4AccountId}
+ then coalesce(${input.connectedAccountEmail}, ${ga4Connections.connectedAccountEmail})
+ else ${input.connectedAccountEmail}
+ end`,
+ updatedAt: sql`(current_timestamp)`,
+ },
+ })
+ .returning();
+ if (!row) throw new Error("Failed to upsert ga4_connection");
+ return row;
+}
+
+async function deleteByProjectId(projectId: string): Promise {
+ await db
+ .delete(ga4Connections)
+ .where(eq(ga4Connections.projectId, projectId));
+}
+
+async function existsForConnectorAccount(
+ userId: string,
+ ga4AccountId: string,
+): Promise {
+ const rows = await db
+ .select({ id: ga4Connections.id })
+ .from(ga4Connections)
+ .where(
+ and(
+ eq(ga4Connections.connectedByUserId, userId),
+ eq(ga4Connections.ga4AccountId, ga4AccountId),
+ ),
+ )
+ .limit(1);
+ return rows.length > 0;
+}
+
+export const Ga4ConnectionRepository = {
+ getByProjectId,
+ upsert,
+ deleteByProjectId,
+ existsForConnectorAccount,
+};
diff --git a/src/server/features/ga4/services/Ga4Dates.ts b/src/server/features/ga4/services/Ga4Dates.ts
new file mode 100644
index 0000000..6ebd83e
--- /dev/null
+++ b/src/server/features/ga4/services/Ga4Dates.ts
@@ -0,0 +1,27 @@
+const DAY_MILLISECONDS = 86_400_000;
+
+export function shiftGa4Date(value: string, days: number): string {
+ const date = new Date(`${value}T00:00:00.000Z`);
+ if (Number.isNaN(date.valueOf())) throw new RangeError("Invalid GA4 date.");
+ date.setUTCDate(date.getUTCDate() + days);
+ return date.toISOString().slice(0, 10);
+}
+
+export function ga4DateInTimeZone(now: Date, timeZone: string): string {
+ const parts = new Intl.DateTimeFormat("en-US", {
+ timeZone,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ }).formatToParts(now);
+ const byType = Object.fromEntries(
+ parts.map((part) => [part.type, part.value]),
+ );
+ return `${byType.year}-${byType.month}-${byType.day}`;
+}
+
+export function inclusiveGa4Days(startDate: string, endDate: string): number {
+ const start = new Date(`${startDate}T00:00:00.000Z`);
+ const end = new Date(`${endDate}T00:00:00.000Z`);
+ return Math.round((end.valueOf() - start.valueOf()) / DAY_MILLISECONDS) + 1;
+}
diff --git a/src/server/features/ga4/services/Ga4MeasurementHealthService.test.ts b/src/server/features/ga4/services/Ga4MeasurementHealthService.test.ts
new file mode 100644
index 0000000..e7284f4
--- /dev/null
+++ b/src/server/features/ga4/services/Ga4MeasurementHealthService.test.ts
@@ -0,0 +1,91 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { makeGa4Connection } from "./ga4-test-fixtures";
+import { Ga4MeasurementHealthService } from "./Ga4MeasurementHealthService";
+
+const mocks = vi.hoisted(() => ({
+ getByProjectId: vi.fn(),
+ listDataStreams: vi.fn(),
+ getEnhancedMeasurementSettings: vi.fn(),
+ listKeyEvents: vi.fn(),
+ listCustomDimensions: vi.fn(),
+ listCustomMetrics: vi.fn(),
+}));
+
+vi.mock("@/server/features/ga4/repositories/Ga4ConnectionRepository", () => ({
+ Ga4ConnectionRepository: { getByProjectId: mocks.getByProjectId },
+}));
+vi.mock("@/server/lib/ga4Client", () => ({
+ createGa4AdminClient: () => ({
+ listDataStreams: mocks.listDataStreams,
+ getEnhancedMeasurementSettings: mocks.getEnhancedMeasurementSettings,
+ listKeyEvents: mocks.listKeyEvents,
+ listCustomDimensions: mocks.listCustomDimensions,
+ listCustomMetrics: mocks.listCustomMetrics,
+ }),
+}));
+
+describe("Ga4MeasurementHealthService", () => {
+ beforeEach(() => {
+ mocks.getByProjectId.mockResolvedValue(makeGa4Connection());
+ mocks.listDataStreams.mockResolvedValue([
+ {
+ name: "properties/123/dataStreams/456",
+ type: "WEB_DATA_STREAM",
+ displayName: "Website",
+ webStreamData: {
+ measurementId: "G-ABC123",
+ defaultUri: "https://example.com",
+ },
+ },
+ ]);
+ mocks.getEnhancedMeasurementSettings.mockResolvedValue({
+ streamEnabled: true,
+ scrollsEnabled: true,
+ outboundClicksEnabled: true,
+ siteSearchEnabled: false,
+ videoEngagementEnabled: true,
+ fileDownloadsEnabled: true,
+ pageChangesEnabled: true,
+ formInteractionsEnabled: false,
+ searchQueryParameter: "q",
+ uriQueryParameter: "",
+ });
+ mocks.listKeyEvents.mockResolvedValue([
+ {
+ eventName: "purchase",
+ countingMethod: "ONCE_PER_EVENT",
+ custom: false,
+ },
+ ]);
+ mocks.listCustomDimensions.mockResolvedValue([]);
+ mocks.listCustomMetrics.mockResolvedValue([]);
+ });
+
+ it("returns a read-only measurement inventory and actionable issues", async () => {
+ const result =
+ await Ga4MeasurementHealthService.getMeasurementHealth("project_1");
+
+ expect(result.summary).toEqual({
+ dataStreamCount: 1,
+ webStreamCount: 1,
+ keyEventCount: 1,
+ customDimensionCount: 0,
+ customMetricCount: 0,
+ issueCount: 1,
+ });
+ expect(result.issues).toEqual(["site_search_measurement_disabled"]);
+ expect(result.webStreams[0]).toMatchObject({
+ streamId: "456",
+ measurementId: "G-ABC123",
+ enhancedMeasurement: { siteSearchEnabled: false },
+ });
+ });
+
+ it("returns a stable not-connected error before calling Google", async () => {
+ mocks.getByProjectId.mockResolvedValue(null);
+ await expect(
+ Ga4MeasurementHealthService.getMeasurementHealth("project_1"),
+ ).rejects.toMatchObject({ code: "ga4_not_connected" });
+ expect(mocks.listDataStreams).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/server/features/ga4/services/Ga4MeasurementHealthService.ts b/src/server/features/ga4/services/Ga4MeasurementHealthService.ts
new file mode 100644
index 0000000..ccd429b
--- /dev/null
+++ b/src/server/features/ga4/services/Ga4MeasurementHealthService.ts
@@ -0,0 +1,96 @@
+import { Ga4ConnectionRepository } from "@/server/features/ga4/repositories/Ga4ConnectionRepository";
+import { createGa4AdminClient } from "@/server/lib/ga4Client";
+import { Ga4ReportError } from "@/server/lib/ga4Errors";
+import { mapGa4ReportError } from "@/server/features/ga4/services/Ga4ReportingService";
+
+async function getMeasurementHealth(projectId: string) {
+ const connection = await Ga4ConnectionRepository.getByProjectId(projectId);
+ if (!connection) {
+ throw new Ga4ReportError(
+ "ga4_not_connected",
+ "Google Analytics is not connected for this project.",
+ );
+ }
+ const client = createGa4AdminClient({
+ userId: connection.connectedByUserId,
+ ga4AccountId: connection.ga4AccountId,
+ });
+ try {
+ const streams = await client.listDataStreams(connection.propertyId);
+ const webStreams = [];
+ for (const stream of streams) {
+ if (stream.type !== "WEB_DATA_STREAM") continue;
+ const enhancedMeasurement = await client.getEnhancedMeasurementSettings(
+ stream.name,
+ );
+ webStreams.push({
+ streamId: stream.name.split("/").at(-1) ?? stream.name,
+ displayName: stream.displayName,
+ measurementId: stream.webStreamData?.measurementId ?? null,
+ defaultUri: stream.webStreamData?.defaultUri ?? null,
+ createTime: stream.createTime ?? null,
+ updateTime: stream.updateTime ?? null,
+ enhancedMeasurement,
+ });
+ }
+ const [keyEvents, customDimensions, customMetrics] = await Promise.all([
+ client.listKeyEvents(connection.propertyId),
+ client.listCustomDimensions(connection.propertyId),
+ client.listCustomMetrics(connection.propertyId),
+ ]);
+ const issues: string[] = [];
+ if (webStreams.length === 0) issues.push("no_web_stream");
+ if (
+ webStreams.length > 0 &&
+ webStreams.every((stream) => !stream.enhancedMeasurement.streamEnabled)
+ ) {
+ issues.push("enhanced_measurement_disabled");
+ }
+ if (
+ webStreams.length > 0 &&
+ webStreams.every(
+ (stream) =>
+ !stream.enhancedMeasurement.streamEnabled ||
+ !stream.enhancedMeasurement.siteSearchEnabled,
+ )
+ ) {
+ issues.push("site_search_measurement_disabled");
+ }
+ if (keyEvents.length === 0) issues.push("no_key_events_configured");
+
+ return {
+ status: "ok" as const,
+ source: {
+ provider: "google_analytics_admin" as const,
+ propertyId: connection.propertyId,
+ propertyDisplayName: connection.propertyDisplayName,
+ },
+ summary: {
+ dataStreamCount: streams.length,
+ webStreamCount: webStreams.length,
+ keyEventCount: keyEvents.length,
+ customDimensionCount: customDimensions.length,
+ customMetricCount: customMetrics.length,
+ issueCount: issues.length,
+ },
+ issues,
+ webStreams,
+ otherStreams: streams
+ .filter((stream) => stream.type !== "WEB_DATA_STREAM")
+ .map((stream) => ({
+ streamId: stream.name.split("/").at(-1) ?? stream.name,
+ type: stream.type,
+ displayName: stream.displayName,
+ })),
+ keyEvents,
+ customDefinitions: {
+ dimensions: customDimensions,
+ metrics: customMetrics,
+ },
+ };
+ } catch (error) {
+ mapGa4ReportError(error);
+ }
+}
+
+export const Ga4MeasurementHealthService = { getMeasurementHealth };
diff --git a/src/server/features/ga4/services/Ga4OrganicOverviewService.test.ts b/src/server/features/ga4/services/Ga4OrganicOverviewService.test.ts
new file mode 100644
index 0000000..f2cfa76
--- /dev/null
+++ b/src/server/features/ga4/services/Ga4OrganicOverviewService.test.ts
@@ -0,0 +1,170 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { makeGa4Connection } from "./ga4-test-fixtures";
+import { Ga4OrganicOverviewService } from "./Ga4OrganicOverviewService";
+
+const mocks = vi.hoisted(() => ({
+ getByProjectId: vi.fn(),
+ runReport: vi.fn(),
+}));
+
+vi.mock("@/server/features/ga4/repositories/Ga4ConnectionRepository", () => ({
+ Ga4ConnectionRepository: { getByProjectId: mocks.getByProjectId },
+}));
+
+vi.mock("@/server/lib/ga4Client", () => ({
+ createGa4DataClient: () => ({ runReport: mocks.runReport }),
+}));
+
+const connection = makeGa4Connection();
+
+const metricHeaders = [
+ "sessions",
+ "activeUsers",
+ "engagedSessions",
+ "engagementRate",
+ "keyEvents",
+ "transactions",
+ "purchaseRevenue",
+].map((name) => ({ name }));
+
+function metricValues(values: string[]) {
+ return values.map((value) => ({ value }));
+}
+
+describe("Ga4OrganicOverviewService", () => {
+ beforeEach(() => {
+ mocks.getByProjectId.mockResolvedValue(connection);
+ });
+
+ it("returns an equal-length comparison and weekly trend", async () => {
+ mocks.runReport
+ .mockResolvedValueOnce({
+ dimensionHeaders: [],
+ metricHeaders,
+ rows: [
+ {
+ dimensionValues: [],
+ metricValues: metricValues([
+ "100",
+ "80",
+ "70",
+ "0.7",
+ "10",
+ "4",
+ "500",
+ ]),
+ },
+ ],
+ rowCount: 1,
+ })
+ .mockResolvedValueOnce({
+ dimensionHeaders: [],
+ metricHeaders,
+ rows: [
+ {
+ dimensionValues: [],
+ metricValues: metricValues([
+ "80",
+ "70",
+ "50",
+ "0.625",
+ "5",
+ "2",
+ "250",
+ ]),
+ },
+ ],
+ rowCount: 1,
+ })
+ .mockResolvedValueOnce({
+ dimensionHeaders: [{ name: "yearWeek" }],
+ metricHeaders,
+ rows: [
+ {
+ dimensionValues: [{ value: "202631" }],
+ metricValues: metricValues([
+ "100",
+ "80",
+ "70",
+ "0.7",
+ "10",
+ "4",
+ "500",
+ ]),
+ },
+ ],
+ rowCount: 1,
+ });
+ const result = await Ga4OrganicOverviewService.getOrganicOverview(
+ {
+ projectId: "project_1",
+ startDate: "2026-07-09",
+ endDate: "2026-08-05",
+ trend: "weekly",
+ },
+ { now: new Date("2026-08-06T15:00:00Z") },
+ );
+
+ expect(result.request.previousDateRange).toEqual({
+ startDate: "2026-06-11",
+ endDate: "2026-07-08",
+ });
+ expect(result.comparison.sessions).toEqual({
+ current: 100,
+ previous: 80,
+ absoluteChange: 20,
+ percentChange: 0.25,
+ });
+ expect(result.trend[0]).toMatchObject({
+ yearWeek: "202631",
+ sessions: 100,
+ });
+ expect(result.diagnostics).toEqual([]);
+ expect(mocks.runReport).toHaveBeenCalledTimes(3);
+ });
+
+ it("flags a material key-event decline with explicit evidence", async () => {
+ const report = (keyEvents: string) => ({
+ dimensionHeaders: [],
+ metricHeaders,
+ rows: [
+ {
+ dimensionValues: [],
+ metricValues: metricValues([
+ "100",
+ "80",
+ "70",
+ "0.7",
+ keyEvents,
+ "0",
+ "0",
+ ]),
+ },
+ ],
+ rowCount: 1,
+ });
+ mocks.runReport
+ .mockResolvedValueOnce(report("3"))
+ .mockResolvedValueOnce(report("10"))
+ .mockResolvedValueOnce({
+ dimensionHeaders: [{ name: "date" }],
+ metricHeaders,
+ rowCount: 0,
+ });
+ const result = await Ga4OrganicOverviewService.getOrganicOverview(
+ { projectId: "project_1", trend: "daily" },
+ { now: new Date("2026-08-06T15:00:00Z") },
+ );
+ expect(result.diagnostics).toHaveLength(1);
+ expect(result.diagnostics[0]?.code).toBe("key_events_sharp_decline");
+ expect(result.diagnostics[0]?.evidence).toEqual({
+ current: 3,
+ previous: 10,
+ percentChange: -0.7,
+ });
+ expect(result.diagnostics[0]?.threshold).toEqual({
+ minimumPreviousKeyEvents: 5,
+ percentChange: -0.5,
+ });
+ });
+});
diff --git a/src/server/features/ga4/services/Ga4OrganicOverviewService.ts b/src/server/features/ga4/services/Ga4OrganicOverviewService.ts
new file mode 100644
index 0000000..058cdbf
--- /dev/null
+++ b/src/server/features/ga4/services/Ga4OrganicOverviewService.ts
@@ -0,0 +1,152 @@
+import { Ga4ConnectionRepository } from "@/server/features/ga4/repositories/Ga4ConnectionRepository";
+import { createGa4DataClient } from "@/server/lib/ga4Client";
+import {
+ buildGa4OverviewRequest,
+ OVERVIEW_METRICS,
+} from "./Ga4ReportDefinitions";
+import { normalizeGa4Response } from "./Ga4ReportNormalization";
+import { comparisonValue, previousPeriod } from "./Ga4ReportEnhancements";
+import { Ga4ReportError } from "@/server/lib/ga4Errors";
+import { mapGa4ReportError, resolveGa4DateRange } from "./Ga4ReportingService";
+
+type Ga4OrganicOverviewInput = {
+ projectId: string;
+ startDate?: string;
+ endDate?: string;
+ trend?: "daily" | "weekly";
+};
+
+function metricComparison(
+ current: Record | null,
+ previous: Record | null,
+) {
+ return Object.fromEntries(
+ OVERVIEW_METRICS.map((metric) => {
+ const currentValue =
+ typeof current?.[metric] === "number" ? current[metric] : null;
+ const previousValue =
+ typeof previous?.[metric] === "number" ? previous[metric] : null;
+ return [metric, comparisonValue(currentValue, previousValue)];
+ }),
+ );
+}
+
+function keyEventDiagnostics(
+ current: Record | null,
+ previous: Record | null,
+ hasLimitedData: boolean,
+) {
+ if (hasLimitedData) return [];
+ const currentValue =
+ typeof current?.keyEvents === "number" ? current.keyEvents : null;
+ const previousValue =
+ typeof previous?.keyEvents === "number" ? previous.keyEvents : null;
+ if (currentValue == null || previousValue == null || previousValue < 5) {
+ return [];
+ }
+ const percentChange = (currentValue - previousValue) / previousValue;
+ if (percentChange > -0.5) return [];
+ return [
+ {
+ code: "key_events_sharp_decline",
+ severity: "warning",
+ message:
+ "Organic key events declined sharply compared with the previous equal-length period.",
+ evidence: {
+ current: currentValue,
+ previous: previousValue,
+ percentChange,
+ },
+ threshold: { minimumPreviousKeyEvents: 5, percentChange: -0.5 },
+ },
+ ];
+}
+
+async function getOrganicOverview(
+ input: Ga4OrganicOverviewInput,
+ opts: { now?: Date } = {},
+) {
+ const connection = await Ga4ConnectionRepository.getByProjectId(
+ input.projectId,
+ );
+ if (!connection) {
+ throw new Ga4ReportError(
+ "ga4_not_connected",
+ "Google Analytics is not connected for this project.",
+ );
+ }
+ const dateRange = resolveGa4DateRange(
+ input,
+ connection.propertyTimeZone,
+ opts.now,
+ );
+ const previousDateRange = previousPeriod(dateRange.resolvedDateRange);
+ const currentRequest = buildGa4OverviewRequest(dateRange.resolvedDateRange);
+ const previousRequest = buildGa4OverviewRequest({
+ ...previousDateRange,
+ });
+ const trend = input.trend ?? "daily";
+ const trendRequest = buildGa4OverviewRequest({
+ ...dateRange.resolvedDateRange,
+ trend,
+ });
+ const client = createGa4DataClient({
+ userId: connection.connectedByUserId,
+ ga4AccountId: connection.ga4AccountId,
+ propertyId: connection.propertyId,
+ });
+
+ try {
+ const [currentResponse, previousResponse, trendResponse] =
+ await Promise.all([
+ client.runReport(currentRequest),
+ client.runReport(previousRequest),
+ client.runReport(trendRequest),
+ ]);
+ const current = normalizeGa4Response(currentResponse, currentRequest);
+ const previous = normalizeGa4Response(previousResponse, previousRequest);
+ const trendReport = normalizeGa4Response(trendResponse, trendRequest);
+ const currentSummary = current.rows[0] ?? null;
+ const previousSummary = previous.rows[0] ?? null;
+ const reports = [current, previous, trendReport];
+ const hasLimitedData = reports.some(
+ (report) => report.reportMetadata.hasLimitedData,
+ );
+ return {
+ status: "ok" as const,
+ source: {
+ provider: "google_analytics" as const,
+ propertyId: connection.propertyId,
+ propertyDisplayName: connection.propertyDisplayName,
+ },
+ request: {
+ requestedDateRange: dateRange.requestedDateRange,
+ resolvedDateRange: dateRange.resolvedDateRange,
+ previousDateRange,
+ propertyTimeZone: connection.propertyTimeZone,
+ currencyCode: connection.propertyCurrencyCode,
+ channel: "organic_search" as const,
+ trend,
+ },
+ current: currentSummary,
+ previous: previousSummary,
+ comparison: metricComparison(currentSummary, previousSummary),
+ trend: trendReport.rows,
+ diagnostics: keyEventDiagnostics(
+ currentSummary,
+ previousSummary,
+ hasLimitedData,
+ ),
+ reportMetadata: {
+ hasLimitedData,
+ reports: reports.map((report) => report.reportMetadata),
+ },
+ quota: trendReport.quota ?? current.quota,
+ warnings: dateRange.warnings,
+ };
+ } catch (error) {
+ mapGa4ReportError(error);
+ }
+}
+
+export const Ga4OrganicOverviewService = { getOrganicOverview };
diff --git a/src/server/features/ga4/services/Ga4ReportDefinitions.ts b/src/server/features/ga4/services/Ga4ReportDefinitions.ts
new file mode 100644
index 0000000..36a344e
--- /dev/null
+++ b/src/server/features/ga4/services/Ga4ReportDefinitions.ts
@@ -0,0 +1,296 @@
+import type { Ga4RunReportRequest } from "@/server/lib/ga4Client";
+
+export type Ga4Channel = "organic_search" | "all";
+export type Ga4ReportKind =
+ | "landing_pages"
+ | "page_performance"
+ | "key_events"
+ | "traffic_acquisition"
+ | "ecommerce_performance"
+ | "site_search"
+ | "audience_breakdown";
+
+type Ga4ReportRequestInput = {
+ kind: Ga4ReportKind;
+ startDate: string;
+ endDate: string;
+ channel: Ga4Channel;
+ limit: number;
+ offset: number;
+ includeDate?: boolean;
+ breakdown?: "event" | "event_and_landing_page";
+ acquisitionBreakdown?: "channel_group" | "source_medium" | "campaign";
+ ecommerceBreakdown?: "item" | "landing_page";
+ ecommerceOnlyWithTransactions?: boolean;
+ audienceBreakdown?: "device" | "country" | "new_vs_returning";
+};
+
+const REPORT_DEFINITIONS = {
+ landing_pages: {
+ dimensions: ["hostName", "landingPage"],
+ metrics: [
+ "sessions",
+ "activeUsers",
+ "engagedSessions",
+ "engagementRate",
+ "keyEvents",
+ "sessionKeyEventRate",
+ "transactions",
+ "purchaseRevenue",
+ ],
+ orderMetric: "sessions",
+ },
+ page_performance: {
+ dimensions: ["hostName", "pagePath"],
+ metrics: [
+ "screenPageViews",
+ "activeUsers",
+ "userEngagementDuration",
+ "keyEvents",
+ ],
+ orderMetric: "screenPageViews",
+ },
+ key_events: {
+ dimensions: ["eventName"],
+ metrics: ["keyEvents", "totalUsers"],
+ orderMetric: "keyEvents",
+ },
+ traffic_acquisition: {
+ dimensions: ["sessionDefaultChannelGroup"],
+ metrics: [
+ "sessions",
+ "activeUsers",
+ "engagedSessions",
+ "engagementRate",
+ "keyEvents",
+ "transactions",
+ "purchaseRevenue",
+ ],
+ orderMetric: "sessions",
+ },
+ ecommerce_performance: {
+ dimensions: ["itemName", "itemId"],
+ metrics: [
+ "itemsViewed",
+ "itemsAddedToCart",
+ "itemsPurchased",
+ "itemRevenue",
+ ],
+ orderMetric: "itemRevenue",
+ },
+ site_search: {
+ dimensions: ["searchTerm"],
+ metrics: [
+ "eventCount",
+ "activeUsers",
+ "sessions",
+ "engagedSessions",
+ "engagementRate",
+ ],
+ orderMetric: "eventCount",
+ },
+ audience_breakdown: {
+ dimensions: ["deviceCategory"],
+ metrics: ["activeUsers", "sessions", "engagementRate", "keyEvents"],
+ orderMetric: "activeUsers",
+ },
+} as const;
+
+export const OVERVIEW_METRICS = [
+ "sessions",
+ "activeUsers",
+ "engagedSessions",
+ "engagementRate",
+ "keyEvents",
+ "transactions",
+ "purchaseRevenue",
+] as const;
+
+function organicFilter() {
+ return {
+ filter: {
+ fieldName: "sessionDefaultChannelGroup",
+ stringFilter: { matchType: "EXACT", value: "Organic Search" },
+ },
+ };
+}
+
+function reportDefinition(input: Ga4ReportRequestInput) {
+ if (
+ input.kind === "ecommerce_performance" &&
+ input.ecommerceBreakdown === "landing_page"
+ ) {
+ return {
+ dimensions: ["hostName", "landingPage"] as const,
+ metrics: ["sessions", "transactions", "purchaseRevenue"] as const,
+ orderMetric: "purchaseRevenue",
+ };
+ }
+ return REPORT_DEFINITIONS[input.kind];
+}
+
+function reportDimensions(
+ input: Ga4ReportRequestInput,
+ defaults: readonly string[],
+): string[] {
+ if (input.kind === "traffic_acquisition") {
+ return [
+ {
+ channel_group: "sessionDefaultChannelGroup",
+ source_medium: "sessionSourceMedium",
+ campaign: "sessionCampaignName",
+ }[input.acquisitionBreakdown ?? "channel_group"],
+ ];
+ }
+ if (input.kind === "audience_breakdown") {
+ return [
+ {
+ device: "deviceCategory",
+ country: "country",
+ new_vs_returning: "newVsReturning",
+ }[input.audienceBreakdown ?? "device"],
+ ];
+ }
+ const dimensions = [...defaults];
+ if (input.kind === "page_performance" && input.includeDate) {
+ dimensions.push("date");
+ }
+ if (
+ input.kind === "key_events" &&
+ input.breakdown === "event_and_landing_page"
+ ) {
+ dimensions.push("hostName", "landingPage");
+ }
+ return dimensions;
+}
+
+function reportFilter(input: Ga4ReportRequestInput): unknown {
+ if (input.kind === "site_search") {
+ return {
+ andGroup: {
+ expressions: [
+ {
+ filter: {
+ fieldName: "eventName",
+ stringFilter: {
+ matchType: "EXACT",
+ value: "view_search_results",
+ },
+ },
+ },
+ {
+ notExpression: {
+ filter: {
+ fieldName: "searchTerm",
+ stringFilter: { matchType: "EXACT", value: "(not set)" },
+ },
+ },
+ },
+ ],
+ },
+ };
+ }
+ return input.channel === "organic_search" ? organicFilter() : undefined;
+}
+
+function metricFilter(input: Ga4ReportRequestInput): unknown {
+ if (input.kind === "key_events") {
+ return {
+ filter: {
+ fieldName: "keyEvents",
+ numericFilter: {
+ operation: "GREATER_THAN",
+ value: { doubleValue: 0 },
+ },
+ },
+ };
+ }
+ if (
+ input.kind === "ecommerce_performance" &&
+ input.ecommerceBreakdown === "landing_page" &&
+ input.ecommerceOnlyWithTransactions
+ ) {
+ return {
+ filter: {
+ fieldName: "transactions",
+ numericFilter: {
+ operation: "GREATER_THAN",
+ value: { doubleValue: 0 },
+ },
+ },
+ };
+ }
+ return undefined;
+}
+
+function effectiveBreakdown(input: Ga4ReportRequestInput): string {
+ if (input.kind === "landing_pages") return "landing_page";
+ if (input.kind === "page_performance") {
+ return input.includeDate ? "page_and_date" : "page";
+ }
+ if (input.kind === "key_events") return input.breakdown ?? "event";
+ if (input.kind === "traffic_acquisition") {
+ return input.acquisitionBreakdown ?? "channel_group";
+ }
+ if (input.kind === "ecommerce_performance") {
+ return input.ecommerceBreakdown ?? "item";
+ }
+ if (input.kind === "site_search") return "search_term";
+ return input.audienceBreakdown ?? "device";
+}
+
+export function getGa4ReportConfiguration(input: Ga4ReportRequestInput) {
+ const definition = reportDefinition(input);
+ return {
+ reportKind: input.kind,
+ breakdown: effectiveBreakdown(input),
+ dimensions: reportDimensions(input, definition.dimensions),
+ metrics: [...definition.metrics],
+ flags: {
+ includeDate: input.includeDate ?? false,
+ onlyWithTransactions: input.ecommerceOnlyWithTransactions ?? false,
+ },
+ };
+}
+
+export function buildGa4ReportRequest(
+ input: Ga4ReportRequestInput,
+): Ga4RunReportRequest {
+ const definition = reportDefinition(input);
+ const dimensions = reportDimensions(input, definition.dimensions);
+ return {
+ dateRanges: [{ startDate: input.startDate, endDate: input.endDate }],
+ dimensions: dimensions.map((name) => ({ name })),
+ metrics: definition.metrics.map((name) => ({ name })),
+ dimensionFilter: reportFilter(input),
+ metricFilter: metricFilter(input),
+ offset: String(input.offset),
+ limit: String(input.limit),
+ orderBys: [{ metric: { metricName: definition.orderMetric }, desc: true }],
+ keepEmptyRows: false,
+ returnPropertyQuota: true,
+ };
+}
+
+export function buildGa4OverviewRequest(input: {
+ startDate: string;
+ endDate: string;
+ trend?: "daily" | "weekly";
+}): Ga4RunReportRequest {
+ const dimensions = input.trend
+ ? [{ name: input.trend === "daily" ? "date" : "yearWeek" }]
+ : [];
+ return {
+ dateRanges: [{ startDate: input.startDate, endDate: input.endDate }],
+ dimensions,
+ metrics: OVERVIEW_METRICS.map((name) => ({ name })),
+ dimensionFilter: organicFilter(),
+ offset: "0",
+ limit: input.trend ? "1000" : "1",
+ orderBys: input.trend
+ ? [{ dimension: { dimensionName: dimensions[0]?.name ?? "date" } }]
+ : [],
+ keepEmptyRows: false,
+ returnPropertyQuota: true,
+ };
+}
diff --git a/src/server/features/ga4/services/Ga4ReportEnhancements.test.ts b/src/server/features/ga4/services/Ga4ReportEnhancements.test.ts
new file mode 100644
index 0000000..3b2507e
--- /dev/null
+++ b/src/server/features/ga4/services/Ga4ReportEnhancements.test.ts
@@ -0,0 +1,212 @@
+import { describe, expect, it } from "vitest";
+import { buildGa4ReportRequest } from "./Ga4ReportDefinitions";
+import {
+ buildEcommerceActivity,
+ buildReportComparison,
+ buildReportSpecificEnhancements,
+ buildSiteSearchActivity,
+ previousPeriod,
+ supportsComparison,
+} from "./Ga4ReportEnhancements";
+import type { NormalizedGa4Report } from "./Ga4ReportNormalization";
+
+const metadata = {
+ dataLossFromOtherRow: false,
+ subjectToThresholding: false,
+ sampling: [],
+ restrictedMetrics: [],
+ emptyReason: null,
+ hasLimitedData: false,
+};
+
+function report(
+ rows: NormalizedGa4Report["rows"],
+ totalRowCount = rows.length,
+): NormalizedGa4Report {
+ return { rows, totalRowCount, reportMetadata: metadata, quota: null };
+}
+
+describe("GA4 report enhancements", () => {
+ it("filters key events and optional transaction-only landing pages", () => {
+ const keyEvents = buildGa4ReportRequest({
+ kind: "key_events",
+ startDate: "2026-07-09",
+ endDate: "2026-08-05",
+ channel: "organic_search",
+ limit: 100,
+ offset: 0,
+ });
+ const ecommerce = buildGa4ReportRequest({
+ kind: "ecommerce_performance",
+ ecommerceBreakdown: "landing_page",
+ ecommerceOnlyWithTransactions: true,
+ startDate: "2026-07-09",
+ endDate: "2026-08-05",
+ channel: "organic_search",
+ limit: 100,
+ offset: 0,
+ });
+ expect(keyEvents.metricFilter).toMatchObject({
+ filter: {
+ fieldName: "keyEvents",
+ numericFilter: { operation: "GREATER_THAN" },
+ },
+ });
+ expect(ecommerce.metricFilter).toMatchObject({
+ filter: {
+ fieldName: "transactions",
+ numericFilter: { operation: "GREATER_THAN" },
+ },
+ });
+ });
+
+ it("compares an equal prior period using the union of row keys", () => {
+ expect(
+ previousPeriod({ startDate: "2026-07-09", endDate: "2026-08-05" }),
+ ).toEqual({ startDate: "2026-06-11", endDate: "2026-07-08" });
+ const comparison = buildReportComparison({
+ current: report([{ eventName: "form_submit", keyEvents: 3 }]),
+ previous: report([
+ { eventName: "form_submit", keyEvents: 0 },
+ { eventName: "book_demo", keyEvents: 2 },
+ ]),
+ previousDateRange: {
+ startDate: "2026-06-11",
+ endDate: "2026-07-08",
+ },
+ dimensions: ["eventName"],
+ metrics: ["keyEvents"],
+ });
+ expect(comparison.coverage.complete).toBe(true);
+ expect(comparison.rows).toEqual([
+ {
+ dimensions: { eventName: "form_submit" },
+ metrics: {
+ keyEvents: {
+ current: 3,
+ previous: 0,
+ absoluteChange: 3,
+ percentChange: null,
+ },
+ },
+ },
+ {
+ dimensions: { eventName: "book_demo" },
+ metrics: {
+ keyEvents: {
+ current: null,
+ previous: 2,
+ absoluteChange: null,
+ percentChange: null,
+ },
+ },
+ },
+ ]);
+ });
+
+ it("limits comparisons to unambiguous low-cardinality breakdowns", () => {
+ expect(
+ supportsComparison({
+ projectId: "project_1",
+ kind: "traffic_acquisition",
+ acquisitionBreakdown: "channel_group",
+ }),
+ ).toBe(true);
+ expect(
+ supportsComparison({
+ projectId: "project_1",
+ kind: "traffic_acquisition",
+ acquisitionBreakdown: "source_medium",
+ }),
+ ).toBe(false);
+ expect(
+ supportsComparison({
+ projectId: "project_1",
+ kind: "audience_breakdown",
+ audienceBreakdown: "country",
+ }),
+ ).toBe(false);
+ });
+
+ it("returns evidence-backed source attribution diagnostics", () => {
+ const result = buildReportSpecificEnhancements(
+ report([
+ { sessionSourceMedium: "google / organic", sessions: 80 },
+ { sessionSourceMedium: "(not set)", sessions: 10 },
+ { sessionSourceMedium: "localhost:6443 / referral", sessions: 5 },
+ { sessionSourceMedium: "::1 / referral", sessions: 1 },
+ { sessionSourceMedium: "LinkedIn / Social", sessions: 3 },
+ { sessionSourceMedium: "linkedin / social", sessions: 2 },
+ ]),
+ {
+ projectId: "project_1",
+ kind: "traffic_acquisition",
+ acquisitionBreakdown: "source_medium",
+ },
+ { startDate: "2026-07-09", endDate: "2026-08-05" },
+ );
+ expect("diagnosticCoverage" in result).toBe(true);
+ if (!("diagnosticCoverage" in result)) throw new Error("Missing coverage");
+ expect(result.diagnosticCoverage).toMatchObject({ complete: true });
+ expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([
+ "attribution_not_set_share_high",
+ "internal_referral_traffic_detected",
+ "source_medium_case_variants_detected",
+ ]);
+ });
+
+ it("suppresses diagnostics and marks activity unknown when coverage is incomplete", () => {
+ const incomplete = report(
+ [{ sessionSourceMedium: "localhost / referral", sessions: 5 }],
+ 2,
+ );
+ const diagnostics = buildReportSpecificEnhancements(
+ incomplete,
+ {
+ projectId: "project_1",
+ kind: "traffic_acquisition",
+ acquisitionBreakdown: "source_medium",
+ },
+ { startDate: "2026-07-09", endDate: "2026-08-05" },
+ );
+ const ecommerce = buildEcommerceActivity(
+ report([{ itemName: "Example", itemsViewed: 1 }], 2),
+ {
+ projectId: "project_1",
+ kind: "ecommerce_performance",
+ ecommerceBreakdown: "item",
+ },
+ { startDate: "2026-07-09", endDate: "2026-08-05" },
+ );
+ expect(diagnostics.diagnostics).toEqual([]);
+ expect(ecommerce.status).toBe("unknown");
+ });
+
+ it("reports scoped ecommerce and site-search activity states", () => {
+ const ecommerce = buildEcommerceActivity(
+ report([]),
+ {
+ projectId: "project_1",
+ kind: "ecommerce_performance",
+ ecommerceBreakdown: "landing_page",
+ channel: "organic_search",
+ },
+ { startDate: "2026-07-09", endDate: "2026-08-05" },
+ );
+ const search = buildSiteSearchActivity(
+ report([{ searchTerm: "seo", eventCount: 4 }]),
+ { startDate: "2026-07-09", endDate: "2026-08-05" },
+ );
+ expect(ecommerce).toMatchObject({
+ status: "none",
+ channel: "organic_search",
+ breakdown: "landing_page",
+ evidence: { transactions: 0, purchaseRevenue: 0 },
+ });
+ expect(search).toMatchObject({
+ status: "detected",
+ searchTermCount: 1,
+ searchEventCount: 4,
+ });
+ });
+});
diff --git a/src/server/features/ga4/services/Ga4ReportEnhancements.ts b/src/server/features/ga4/services/Ga4ReportEnhancements.ts
new file mode 100644
index 0000000..b9d2274
--- /dev/null
+++ b/src/server/features/ga4/services/Ga4ReportEnhancements.ts
@@ -0,0 +1,360 @@
+import type { Ga4ReportInput } from "./Ga4ReportingService";
+import type { NormalizedGa4Report } from "./Ga4ReportNormalization";
+import { inclusiveGa4Days, shiftGa4Date } from "./Ga4Dates";
+
+export const COMPLETE_REPORT_LIMIT = 1_000;
+
+type DateRange = { startDate: string; endDate: string };
+type ReportRow = Record;
+
+export function previousPeriod(range: DateRange): DateRange {
+ const days = inclusiveGa4Days(range.startDate, range.endDate);
+ const endDate = shiftGa4Date(range.startDate, -1);
+ return { startDate: shiftGa4Date(endDate, -(days - 1)), endDate };
+}
+
+export function supportsComparison(input: Ga4ReportInput): boolean {
+ if (input.kind === "key_events") {
+ return (input.breakdown ?? "event") === "event";
+ }
+ if (input.kind === "traffic_acquisition") {
+ return (input.acquisitionBreakdown ?? "channel_group") === "channel_group";
+ }
+ if (input.kind === "audience_breakdown") {
+ return ["device", "new_vs_returning"].includes(
+ input.audienceBreakdown ?? "device",
+ );
+ }
+ return false;
+}
+
+export function needsCompleteReport(input: Ga4ReportInput): boolean {
+ return (
+ input.comparePreviousPeriod === true ||
+ (input.kind === "traffic_acquisition" &&
+ input.acquisitionBreakdown === "source_medium") ||
+ input.kind === "ecommerce_performance" ||
+ input.kind === "site_search"
+ );
+}
+
+function metricValue(
+ row: ReportRow | undefined,
+ metric: string,
+): number | null {
+ const value = row?.[metric];
+ return typeof value === "number" ? value : null;
+}
+
+export function comparisonValue(
+ current: number | null,
+ previous: number | null,
+) {
+ const absoluteChange =
+ current != null && previous != null ? current - previous : null;
+ return {
+ current,
+ previous,
+ absoluteChange,
+ percentChange:
+ absoluteChange != null && previous != null && previous !== 0
+ ? absoluteChange / previous
+ : null,
+ };
+}
+
+function rowKey(row: ReportRow, dimensions: string[]): string {
+ return JSON.stringify(dimensions.map((dimension) => row[dimension] ?? null));
+}
+
+export function buildReportComparison(input: {
+ current: NormalizedGa4Report;
+ previous: NormalizedGa4Report;
+ previousDateRange: DateRange;
+ dimensions: string[];
+ metrics: string[];
+}) {
+ const currentByKey = new Map(
+ input.current.rows.map((row) => [rowKey(row, input.dimensions), row]),
+ );
+ const previousByKey = new Map(
+ input.previous.rows.map((row) => [rowKey(row, input.dimensions), row]),
+ );
+ const keys = [
+ ...currentByKey.keys(),
+ ...[...previousByKey.keys()].filter((key) => !currentByKey.has(key)),
+ ];
+ const currentComplete =
+ input.current.rows.length === input.current.totalRowCount;
+ const previousComplete =
+ input.previous.rows.length === input.previous.totalRowCount;
+ return {
+ previousDateRange: input.previousDateRange,
+ dimensions: input.dimensions,
+ metrics: input.metrics,
+ rows: keys.map((key) => {
+ const current = currentByKey.get(key);
+ const previous = previousByKey.get(key);
+ return {
+ dimensions: Object.fromEntries(
+ input.dimensions.map((dimension) => [
+ dimension,
+ current?.[dimension] ?? previous?.[dimension] ?? null,
+ ]),
+ ),
+ metrics: Object.fromEntries(
+ input.metrics.map((metric) => [
+ metric,
+ comparisonValue(
+ metricValue(current, metric),
+ metricValue(previous, metric),
+ ),
+ ]),
+ ),
+ };
+ }),
+ coverage: {
+ complete: currentComplete && previousComplete,
+ current: {
+ fetchedRowCount: input.current.rows.length,
+ totalRowCount: input.current.totalRowCount,
+ },
+ previous: {
+ fetchedRowCount: input.previous.rows.length,
+ totalRowCount: input.previous.totalRowCount,
+ },
+ },
+ reportMetadata: {
+ hasLimitedData:
+ input.current.reportMetadata.hasLimitedData ||
+ input.previous.reportMetadata.hasLimitedData,
+ current: input.current.reportMetadata,
+ previous: input.previous.reportMetadata,
+ },
+ quota: input.previous.quota,
+ };
+}
+
+function isInternalHost(value: string): boolean {
+ const source = value.split(" / ")[0]?.toLowerCase() ?? "";
+ const withoutProtocol = source.replace(/^https?:\/\//, "");
+ if (withoutProtocol === "::1" || withoutProtocol.startsWith("[::1]")) {
+ return true;
+ }
+ const host = withoutProtocol.split(":")[0] ?? "";
+ if (host === "localhost" || host.startsWith("127.")) {
+ return true;
+ }
+ const parts = host.split(".").map(Number);
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) {
+ return false;
+ }
+ return (
+ parts[0] === 10 ||
+ (parts[0] === 172 && (parts[1] ?? 0) >= 16 && (parts[1] ?? 0) <= 31) ||
+ (parts[0] === 192 && parts[1] === 168)
+ );
+}
+
+function buildAttributionDiagnostics(report: NormalizedGa4Report) {
+ const complete = report.rows.length === report.totalRowCount;
+ const coverage = {
+ complete,
+ limitedData: report.reportMetadata.hasLimitedData,
+ fetchedRowCount: report.rows.length,
+ totalRowCount: report.totalRowCount,
+ };
+ if (!complete || report.reportMetadata.hasLimitedData) {
+ return { diagnostics: [], diagnosticCoverage: coverage };
+ }
+ const diagnostics: Array> = [];
+ const sessions = report.rows.reduce(
+ (sum, row) => sum + (metricValue(row, "sessions") ?? 0),
+ 0,
+ );
+ const notSetSessions = report.rows
+ .filter((row) => row.sessionSourceMedium === "(not set)")
+ .reduce((sum, row) => sum + (metricValue(row, "sessions") ?? 0), 0);
+ const notSetShare = sessions > 0 ? notSetSessions / sessions : 0;
+ if (notSetShare >= 0.05) {
+ diagnostics.push({
+ code: "attribution_not_set_share_high",
+ severity: "warning",
+ message: "A notable share of sessions has no source/medium attribution.",
+ evidence: {
+ sessions: notSetSessions,
+ totalSessions: sessions,
+ share: notSetShare,
+ },
+ threshold: { share: 0.05 },
+ });
+ }
+ const internalRows = report.rows.filter(
+ (row) =>
+ typeof row.sessionSourceMedium === "string" &&
+ isInternalHost(row.sessionSourceMedium),
+ );
+ const internalSessions = internalRows.reduce(
+ (sum, row) => sum + (metricValue(row, "sessions") ?? 0),
+ 0,
+ );
+ if (internalSessions > 0) {
+ diagnostics.push({
+ code: "internal_referral_traffic_detected",
+ severity: "warning",
+ message:
+ "Local or private-network referral sources appear in acquisition data.",
+ evidence: {
+ sessions: internalSessions,
+ sources: internalRows.map((row) => row.sessionSourceMedium),
+ },
+ threshold: { sessions: 0 },
+ });
+ }
+ const caseGroups = new Map>();
+ for (const row of report.rows) {
+ if (typeof row.sessionSourceMedium !== "string") continue;
+ const key = row.sessionSourceMedium.toLowerCase();
+ const variants = caseGroups.get(key) ?? new Set();
+ variants.add(row.sessionSourceMedium);
+ caseGroups.set(key, variants);
+ }
+ const variantGroups = [...caseGroups.values()]
+ .filter((variants) => variants.size > 1)
+ .map((variants) => [...variants]);
+ if (variantGroups.length > 0) {
+ diagnostics.push({
+ code: "source_medium_case_variants_detected",
+ severity: "info",
+ message: "Source/medium values differ only by letter casing.",
+ evidence: { variantGroups },
+ threshold: { variantGroups: 0 },
+ });
+ }
+ return { diagnostics, diagnosticCoverage: coverage };
+}
+
+function activityStatus(
+ report: NormalizedGa4Report,
+ detected: boolean,
+): "detected" | "none" | "unknown" {
+ if (
+ report.reportMetadata.hasLimitedData ||
+ report.rows.length !== report.totalRowCount
+ ) {
+ return "unknown";
+ }
+ return detected ? "detected" : "none";
+}
+
+export function buildEcommerceActivity(
+ report: NormalizedGa4Report,
+ input: Ga4ReportInput,
+ dateRange: DateRange,
+) {
+ const breakdown = input.ecommerceBreakdown ?? "item";
+ const metrics =
+ breakdown === "item"
+ ? ["itemsViewed", "itemsAddedToCart", "itemsPurchased", "itemRevenue"]
+ : ["transactions", "purchaseRevenue"];
+ const totals = Object.fromEntries(
+ metrics.map((metric) => [
+ metric,
+ report.rows.reduce(
+ (sum, row) => sum + (metricValue(row, metric) ?? 0),
+ 0,
+ ),
+ ]),
+ );
+ const detected = Object.values(totals).some((value) => value > 0);
+ const status = activityStatus(report, detected);
+ return {
+ status,
+ dateRange,
+ channel: input.channel ?? "organic_search",
+ breakdown,
+ evidence: totals,
+ reason:
+ status === "none"
+ ? "No matching ecommerce activity was reported for this period and channel."
+ : status === "unknown"
+ ? "The fetched report is incomplete or limited, so ecommerce activity cannot be determined."
+ : null,
+ };
+}
+
+export function buildSiteSearchActivity(
+ report: NormalizedGa4Report,
+ dateRange: DateRange,
+) {
+ const eventCount = report.rows.reduce(
+ (sum, row) => sum + (metricValue(row, "eventCount") ?? 0),
+ 0,
+ );
+ const status = activityStatus(report, eventCount > 0);
+ return {
+ status,
+ dateRange,
+ searchTermCount: report.totalRowCount,
+ searchEventCount: eventCount,
+ reason:
+ status === "none"
+ ? "No measured site-search terms were reported for this period."
+ : status === "unknown"
+ ? "The fetched report is incomplete or limited, so site-search activity cannot be determined."
+ : null,
+ };
+}
+
+export function buildReportSpecificEnhancements(
+ report: NormalizedGa4Report,
+ input: Ga4ReportInput,
+ dateRange: DateRange,
+) {
+ if (
+ input.kind === "traffic_acquisition" &&
+ input.acquisitionBreakdown === "source_medium"
+ ) {
+ return buildAttributionDiagnostics(report);
+ }
+ if (input.kind === "ecommerce_performance") {
+ const ecommerceActivity = buildEcommerceActivity(report, input, dateRange);
+ return {
+ diagnostics:
+ ecommerceActivity.status === "none"
+ ? [
+ {
+ code: "no_ecommerce_activity",
+ severity: "info",
+ message: ecommerceActivity.reason,
+ evidence: ecommerceActivity.evidence,
+ threshold: { matchingActivity: 0 },
+ },
+ ]
+ : [],
+ ecommerceActivity,
+ };
+ }
+ if (input.kind === "site_search") {
+ const siteSearchActivity = buildSiteSearchActivity(report, dateRange);
+ return {
+ diagnostics:
+ siteSearchActivity.status === "none"
+ ? [
+ {
+ code: "no_site_search_activity",
+ severity: "info",
+ message: siteSearchActivity.reason,
+ evidence: {
+ searchTermCount: siteSearchActivity.searchTermCount,
+ searchEventCount: siteSearchActivity.searchEventCount,
+ },
+ threshold: { searchEvents: 0 },
+ },
+ ]
+ : [],
+ siteSearchActivity,
+ };
+ }
+ return { diagnostics: [] };
+}
diff --git a/src/server/features/ga4/services/Ga4ReportNormalization.ts b/src/server/features/ga4/services/Ga4ReportNormalization.ts
new file mode 100644
index 0000000..556c9ff
--- /dev/null
+++ b/src/server/features/ga4/services/Ga4ReportNormalization.ts
@@ -0,0 +1,107 @@
+import {
+ type Ga4RunReportRequest,
+ type Ga4RunReportResponse,
+} from "@/server/lib/ga4Client";
+import { Ga4MalformedResponseError } from "@/server/lib/ga4Errors";
+
+type Ga4QuotaStatus = { consumed: number; remaining: number };
+export type Ga4Quota = {
+ tokensPerDay?: Ga4QuotaStatus;
+ tokensPerHour?: Ga4QuotaStatus;
+ concurrentRequests?: Ga4QuotaStatus;
+ serverErrorsPerProjectPerHour?: Ga4QuotaStatus;
+ potentiallyThresholdedRequestsPerHour?: Ga4QuotaStatus;
+ tokensPerProjectPerHour?: Ga4QuotaStatus;
+};
+
+export type Ga4ReportMetadata = {
+ dataLossFromOtherRow: boolean;
+ subjectToThresholding: boolean;
+ sampling: Array<{
+ samplesReadCount: string;
+ samplingSpaceSize: string;
+ }>;
+ restrictedMetrics: Array<{
+ metricName: string;
+ restrictedMetricTypes: string[];
+ }>;
+ emptyReason: string | null;
+ hasLimitedData: boolean;
+};
+
+export type NormalizedGa4Report = {
+ rows: Array>;
+ totalRowCount: number;
+ reportMetadata: Ga4ReportMetadata;
+ quota: Ga4Quota | null;
+};
+
+function parseFiniteMetric(value: string): number {
+ const parsed = Number(value);
+ if (!Number.isFinite(parsed)) throw new Ga4MalformedResponseError();
+ return parsed;
+}
+
+export function normalizeGa4Response(
+ response: Ga4RunReportResponse,
+ request: Ga4RunReportRequest,
+): NormalizedGa4Report {
+ const expectedDimensions = request.dimensions.map(({ name }) => name);
+ const expectedMetrics = request.metrics.map(({ name }) => name);
+ const dimensions = (response.dimensionHeaders ?? []).map(({ name }) => name);
+ const metrics = (response.metricHeaders ?? []).map(({ name }) => name);
+ if (
+ dimensions.join("\0") !== expectedDimensions.join("\0") ||
+ metrics.join("\0") !== expectedMetrics.join("\0")
+ ) {
+ throw new Ga4MalformedResponseError();
+ }
+
+ const restrictedMetrics =
+ response.metadata?.schemaRestrictionResponse?.activeMetricRestrictions?.map(
+ (restriction) => ({
+ metricName: restriction.metricName,
+ restrictedMetricTypes: restriction.restrictedMetricTypes ?? [],
+ }),
+ ) ?? [];
+ const restrictedNames = new Set(
+ restrictedMetrics.map((restriction) => restriction.metricName),
+ );
+ const rows = (response.rows ?? []).map((row) => {
+ if (
+ (row.dimensionValues?.length ?? 0) !== dimensions.length ||
+ (row.metricValues?.length ?? 0) !== metrics.length
+ ) {
+ throw new Ga4MalformedResponseError();
+ }
+ const normalized: Record = {};
+ dimensions.forEach((name, index) => {
+ normalized[name] = row.dimensionValues?.[index]?.value ?? "";
+ });
+ metrics.forEach((name, index) => {
+ normalized[name] = restrictedNames.has(name)
+ ? null
+ : parseFiniteMetric(row.metricValues?.[index]?.value ?? "");
+ });
+ return normalized;
+ });
+ const sampling = response.metadata?.samplingMetadatas ?? [];
+ const reportMetadata = {
+ dataLossFromOtherRow: response.metadata?.dataLossFromOtherRow ?? false,
+ subjectToThresholding: response.metadata?.subjectToThresholding ?? false,
+ sampling,
+ restrictedMetrics,
+ emptyReason: response.metadata?.emptyReason ?? null,
+ hasLimitedData:
+ (response.metadata?.dataLossFromOtherRow ?? false) ||
+ (response.metadata?.subjectToThresholding ?? false) ||
+ sampling.length > 0 ||
+ restrictedMetrics.length > 0,
+ };
+ return {
+ rows,
+ totalRowCount: response.rowCount ?? rows.length,
+ reportMetadata,
+ quota: response.propertyQuota ?? null,
+ };
+}
diff --git a/src/server/features/ga4/services/Ga4ReportingService.test.ts b/src/server/features/ga4/services/Ga4ReportingService.test.ts
new file mode 100644
index 0000000..1ab8dfb
--- /dev/null
+++ b/src/server/features/ga4/services/Ga4ReportingService.test.ts
@@ -0,0 +1,392 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type {
+ Ga4RunReportRequest,
+ Ga4RunReportResponse,
+} from "@/server/lib/ga4Client";
+import { Ga4DataApiError, Ga4ReportError } from "@/server/lib/ga4Errors";
+import { makeGa4Connection } from "./ga4-test-fixtures";
+import { Ga4ReportingService } from "./Ga4ReportingService";
+
+const mocks = vi.hoisted(() => ({
+ getByProjectId: vi.fn(),
+ runReport:
+ vi.fn<(request: Ga4RunReportRequest) => Promise>(),
+}));
+
+vi.mock("@/server/features/ga4/repositories/Ga4ConnectionRepository", () => ({
+ Ga4ConnectionRepository: { getByProjectId: mocks.getByProjectId },
+}));
+
+vi.mock("@/server/lib/ga4Client", () => ({
+ createGa4DataClient: () => ({ runReport: mocks.runReport }),
+}));
+
+const connection = makeGa4Connection();
+
+const landingHeaders = {
+ dimensionHeaders: [{ name: "hostName" }, { name: "landingPage" }],
+ metricHeaders: [
+ "sessions",
+ "activeUsers",
+ "engagedSessions",
+ "engagementRate",
+ "keyEvents",
+ "sessionKeyEventRate",
+ "transactions",
+ "purchaseRevenue",
+ ].map((name) => ({ name })),
+};
+
+const acquisitionMetricNames = [
+ "sessions",
+ "activeUsers",
+ "engagedSessions",
+ "engagementRate",
+ "keyEvents",
+ "transactions",
+ "purchaseRevenue",
+];
+
+function acquisitionResponse(
+ start: number,
+ length: number,
+): Ga4RunReportResponse {
+ return {
+ dimensionHeaders: [{ name: "sessionSourceMedium" }],
+ metricHeaders: acquisitionMetricNames.map((name) => ({ name })),
+ rows: Array.from({ length }, (_, index) => ({
+ dimensionValues: [{ value: `source-${start + index} / referral` }],
+ metricValues: acquisitionMetricNames.map(() => ({ value: "1" })),
+ })),
+ rowCount: 1_100,
+ };
+}
+
+describe("Ga4ReportingService", () => {
+ beforeEach(() => {
+ mocks.getByProjectId.mockResolvedValue(connection);
+ });
+
+ it("builds and normalizes the organic landing-page report", async () => {
+ mocks.runReport.mockResolvedValue({
+ ...landingHeaders,
+ rows: [
+ {
+ dimensionValues: [{ value: "example.com" }, { value: "/guides/seo" }],
+ metricValues: ["20", "18", "14", "0.7", "3", "0.15", "1", "99.5"].map(
+ (value) => ({ value }),
+ ),
+ },
+ ],
+ rowCount: 3,
+ metadata: {
+ subjectToThresholding: true,
+ samplingMetadatas: [
+ { samplesReadCount: "1000", samplingSpaceSize: "10000" },
+ ],
+ },
+ propertyQuota: {
+ tokensPerDay: { consumed: 10, remaining: 90 },
+ },
+ });
+ const result = await Ga4ReportingService.runReport(
+ {
+ projectId: "project_1",
+ kind: "landing_pages",
+ limit: 1,
+ offset: 1,
+ },
+ { now: new Date("2026-08-06T15:00:00Z") },
+ );
+
+ expect(mocks.runReport).toHaveBeenCalledWith(
+ expect.objectContaining({
+ dateRanges: [{ startDate: "2026-07-09", endDate: "2026-08-05" }],
+ dimensionFilter: {
+ filter: {
+ fieldName: "sessionDefaultChannelGroup",
+ stringFilter: { matchType: "EXACT", value: "Organic Search" },
+ },
+ },
+ offset: "1",
+ limit: "1",
+ }),
+ );
+ expect(result.rows[0]).toMatchObject({
+ hostName: "example.com",
+ landingPage: "/guides/seo",
+ sessions: 20,
+ purchaseRevenue: 99.5,
+ });
+ expect(result.pageInfo).toEqual({
+ offset: 1,
+ limit: 1,
+ hasMore: true,
+ nextOffset: 2,
+ });
+ expect(result.reportMetadata.hasLimitedData).toBe(true);
+ expect(result.quota?.tokensPerDay).toEqual({ consumed: 10, remaining: 90 });
+ expect(result.request).toMatchObject({
+ reportKind: "landing_pages",
+ breakdown: "landing_page",
+ dimensions: ["hostName", "landingPage"],
+ flags: { includeDate: false, onlyWithTransactions: false },
+ });
+ });
+
+ it("clamps explicit dates and nulls restricted metrics", async () => {
+ mocks.runReport.mockResolvedValue({
+ ...landingHeaders,
+ rows: [
+ {
+ dimensionValues: [{ value: "example.com" }, { value: "/" }],
+ metricValues: ["1", "1", "1", "1", "1", "1", "1", "0"].map(
+ (value) => ({ value }),
+ ),
+ },
+ ],
+ rowCount: 1,
+ metadata: {
+ schemaRestrictionResponse: {
+ activeMetricRestrictions: [
+ {
+ metricName: "purchaseRevenue",
+ restrictedMetricTypes: ["COST_DATA"],
+ },
+ ],
+ },
+ },
+ });
+ const result = await Ga4ReportingService.runReport(
+ {
+ projectId: "project_1",
+ kind: "landing_pages",
+ startDate: "2025-01-01",
+ endDate: "2026-08-20",
+ },
+ { now: new Date("2026-08-06T15:00:00Z") },
+ );
+
+ expect(result.request.resolvedDateRange).toEqual({
+ startDate: "2026-05-08",
+ endDate: "2026-08-05",
+ });
+ expect(result.warnings).toEqual(["end_date_clamped", "start_date_clamped"]);
+ expect(result.rows[0]?.purchaseRevenue).toBeNull();
+ });
+
+ it("uses the all-channel page report and optional date dimension", async () => {
+ mocks.runReport.mockResolvedValue({
+ dimensionHeaders: [
+ { name: "hostName" },
+ { name: "pagePath" },
+ { name: "date" },
+ ],
+ metricHeaders: [
+ "screenPageViews",
+ "activeUsers",
+ "userEngagementDuration",
+ "keyEvents",
+ ].map((name) => ({ name })),
+ rowCount: 0,
+ });
+ await Ga4ReportingService.runReport(
+ {
+ projectId: "project_1",
+ kind: "page_performance",
+ channel: "all",
+ includeDate: true,
+ },
+ { now: new Date("2026-08-06T15:00:00Z") },
+ );
+ expect(mocks.runReport).toHaveBeenCalledWith(
+ expect.objectContaining({
+ dimensions: [
+ { name: "hostName" },
+ { name: "pagePath" },
+ { name: "date" },
+ ],
+ dimensionFilter: undefined,
+ }),
+ );
+ });
+
+ it("returns stable connection and quota errors", async () => {
+ mocks.getByProjectId.mockResolvedValueOnce(null);
+ await expect(
+ Ga4ReportingService.runReport({
+ projectId: "project_1",
+ kind: "landing_pages",
+ }),
+ ).rejects.toMatchObject({ code: "ga4_not_connected" });
+
+ mocks.runReport.mockRejectedValueOnce(
+ new Ga4DataApiError(429, "quota", 60),
+ );
+ const quotaFailure = Ga4ReportingService.runReport({
+ projectId: "project_1",
+ kind: "landing_pages",
+ });
+ await expect(quotaFailure).rejects.toBeInstanceOf(Ga4ReportError);
+ await expect(quotaFailure).rejects.toMatchObject({
+ code: "ga4_quota_exhausted",
+ retryAfterSeconds: 60,
+ });
+ });
+
+ it("rejects half-ranges and reversed dates before calling Google", async () => {
+ await expect(
+ Ga4ReportingService.runReport({
+ projectId: "project_1",
+ kind: "landing_pages",
+ startDate: "2026-07-01",
+ }),
+ ).rejects.toMatchObject({ code: "validation_error" });
+ await expect(
+ Ga4ReportingService.runReport({
+ projectId: "project_1",
+ kind: "landing_pages",
+ startDate: "2026-07-20",
+ endDate: "2026-07-01",
+ }),
+ ).rejects.toMatchObject({ code: "validation_error" });
+ expect(mocks.runReport).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ [
+ "traffic_acquisition",
+ { acquisitionBreakdown: "source_medium", channel: "all" },
+ "sessionSourceMedium",
+ [
+ "sessions",
+ "activeUsers",
+ "engagedSessions",
+ "engagementRate",
+ "keyEvents",
+ "transactions",
+ "purchaseRevenue",
+ ],
+ ],
+ [
+ "ecommerce_performance",
+ { ecommerceBreakdown: "item", channel: "organic_search" },
+ "itemName",
+ ["itemsViewed", "itemsAddedToCart", "itemsPurchased", "itemRevenue"],
+ ],
+ [
+ "site_search",
+ { channel: "all" },
+ "searchTerm",
+ [
+ "eventCount",
+ "activeUsers",
+ "sessions",
+ "engagedSessions",
+ "engagementRate",
+ ],
+ ],
+ [
+ "audience_breakdown",
+ { audienceBreakdown: "new_vs_returning", channel: "organic_search" },
+ "newVsReturning",
+ ["activeUsers", "sessions", "engagementRate", "keyEvents"],
+ ],
+ ] as const)(
+ "builds the fixed %s report",
+ async (kind, options, dimension, metrics) => {
+ mocks.runReport.mockResolvedValue({
+ dimensionHeaders:
+ kind === "ecommerce_performance"
+ ? [{ name: "itemName" }, { name: "itemId" }]
+ : [{ name: dimension }],
+ metricHeaders: metrics.map((name) => ({ name })),
+ rowCount: 0,
+ });
+ await Ga4ReportingService.runReport(
+ { projectId: "project_1", kind, ...options },
+ { now: new Date("2026-08-06T15:00:00Z") },
+ );
+ const request = mocks.runReport.mock.calls[0]?.[0];
+ expect(request?.dimensions[0]?.name).toBe(dimension);
+ expect(request?.metrics).toEqual(metrics.map((name) => ({ name })));
+ if (kind === "site_search") {
+ expect(request?.dimensionFilter).toEqual({
+ andGroup: {
+ expressions: [
+ {
+ filter: {
+ fieldName: "eventName",
+ stringFilter: {
+ matchType: "EXACT",
+ value: "view_search_results",
+ },
+ },
+ },
+ {
+ notExpression: {
+ filter: {
+ fieldName: "searchTerm",
+ stringFilter: {
+ matchType: "EXACT",
+ value: "(not set)",
+ },
+ },
+ },
+ },
+ ],
+ },
+ });
+ }
+ },
+ );
+
+ describe("diagnostic pagination", () => {
+ it.each([
+ { offset: 950, hasMore: true, nextOffset: 1_050 },
+ { offset: 1_000, hasMore: false, nextOffset: null },
+ ])(
+ "fetches the real page beyond the diagnostic buffer at offset $offset",
+ async ({ offset, hasMore, nextOffset }) => {
+ mocks.runReport
+ .mockResolvedValueOnce(acquisitionResponse(0, 1_000))
+ .mockResolvedValueOnce(acquisitionResponse(offset, 100));
+
+ const result = await Ga4ReportingService.runReport(
+ {
+ projectId: "project_1",
+ kind: "traffic_acquisition",
+ acquisitionBreakdown: "source_medium",
+ channel: "all",
+ offset,
+ limit: 100,
+ },
+ { now: new Date("2026-08-06T15:00:00Z") },
+ );
+
+ expect(mocks.runReport).toHaveBeenCalledTimes(2);
+ expect(mocks.runReport.mock.calls[1]?.[0]).toMatchObject({
+ offset: String(offset),
+ limit: "100",
+ });
+ expect(result.rows).toHaveLength(100);
+ expect(result.rows[0]?.sessionSourceMedium).toBe(
+ `source-${offset} / referral`,
+ );
+ expect(result.pageInfo).toEqual({
+ offset,
+ limit: 100,
+ hasMore,
+ nextOffset,
+ });
+ expect(result).toMatchObject({
+ diagnosticCoverage: {
+ complete: false,
+ fetchedRowCount: 1_000,
+ totalRowCount: 1_100,
+ },
+ });
+ },
+ );
+ });
+});
diff --git a/src/server/features/ga4/services/Ga4ReportingService.ts b/src/server/features/ga4/services/Ga4ReportingService.ts
new file mode 100644
index 0000000..9b3eb77
--- /dev/null
+++ b/src/server/features/ga4/services/Ga4ReportingService.ts
@@ -0,0 +1,377 @@
+import { z } from "zod";
+import { createGa4DataClient } from "@/server/lib/ga4Client";
+import {
+ Ga4AdminApiError,
+ Ga4DataApiError,
+ Ga4MalformedResponseError,
+ Ga4ReportError,
+ Ga4TokenError,
+} from "@/server/lib/ga4Errors";
+import { Ga4ConnectionRepository } from "@/server/features/ga4/repositories/Ga4ConnectionRepository";
+import {
+ buildGa4ReportRequest,
+ getGa4ReportConfiguration,
+ type Ga4Channel,
+ type Ga4ReportKind,
+} from "./Ga4ReportDefinitions";
+import {
+ buildReportComparison,
+ buildReportSpecificEnhancements,
+ COMPLETE_REPORT_LIMIT,
+ needsCompleteReport,
+ previousPeriod,
+ supportsComparison,
+} from "./Ga4ReportEnhancements";
+import { normalizeGa4Response } from "./Ga4ReportNormalization";
+import { ga4DateInTimeZone, shiftGa4Date } from "./Ga4Dates";
+
+export type Ga4ReportInput = {
+ projectId: string;
+ kind: Ga4ReportKind;
+ startDate?: string;
+ endDate?: string;
+ limit?: number;
+ offset?: number;
+ channel?: Ga4Channel;
+ includeDate?: boolean;
+ breakdown?: "event" | "event_and_landing_page";
+ acquisitionBreakdown?: "channel_group" | "source_medium" | "campaign";
+ ecommerceBreakdown?: "item" | "landing_page";
+ ecommerceOnlyWithTransactions?: boolean;
+ audienceBreakdown?: "device" | "country" | "new_vs_returning";
+ comparePreviousPeriod?: boolean;
+};
+
+const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
+const DEFAULT_LIMIT = 100;
+const MAX_LIMIT = 1_000;
+
+function parseDate(value: string): Date | null {
+ if (!DATE_PATTERN.test(value)) return null;
+ const date = new Date(`${value}T00:00:00.000Z`);
+ return Number.isNaN(date.valueOf()) ||
+ date.toISOString().slice(0, 10) !== value
+ ? null
+ : date;
+}
+
+export function resolveGa4DateRange(
+ input: Pick,
+ propertyTimeZone: string,
+ now: Date = new Date(),
+) {
+ if (Boolean(input.startDate) !== Boolean(input.endDate)) {
+ throw new Ga4ReportError(
+ "validation_error",
+ "Provide both startDate and endDate, or neither.",
+ );
+ }
+ const requestedDateRange =
+ input.startDate && input.endDate
+ ? { startDate: input.startDate, endDate: input.endDate }
+ : null;
+ if (
+ requestedDateRange &&
+ (!parseDate(requestedDateRange.startDate) ||
+ !parseDate(requestedDateRange.endDate) ||
+ requestedDateRange.startDate > requestedDateRange.endDate)
+ ) {
+ throw new Ga4ReportError(
+ "validation_error",
+ "Dates must be valid YYYY-MM-DD values with startDate on or before endDate.",
+ );
+ }
+
+ const lastCompleteDay = shiftGa4Date(
+ ga4DateInTimeZone(now, propertyTimeZone),
+ -1,
+ );
+ let endDate = requestedDateRange?.endDate ?? lastCompleteDay;
+ let startDate = requestedDateRange?.startDate ?? shiftGa4Date(endDate, -27);
+ const warnings: string[] = [];
+ if (endDate > lastCompleteDay) {
+ endDate = lastCompleteDay;
+ warnings.push("end_date_clamped");
+ }
+ const ninetyDayFloor = shiftGa4Date(endDate, -89);
+ if (startDate < ninetyDayFloor) {
+ startDate = ninetyDayFloor;
+ warnings.push("start_date_clamped");
+ }
+ if (startDate > endDate) {
+ throw new Ga4ReportError(
+ "validation_error",
+ "The resolved startDate is after the last complete Analytics day.",
+ );
+ }
+ return {
+ requestedDateRange,
+ resolvedDateRange: { startDate, endDate },
+ warnings,
+ };
+}
+
+function normalizeLimit(value: number | undefined): number {
+ const limit = value ?? DEFAULT_LIMIT;
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
+ throw new Ga4ReportError(
+ "validation_error",
+ `limit must be an integer from 1 to ${MAX_LIMIT}.`,
+ );
+ }
+ return limit;
+}
+
+function normalizeOffset(value: number | undefined): number {
+ const offset = value ?? 0;
+ if (!Number.isInteger(offset) || offset < 0) {
+ throw new Ga4ReportError(
+ "validation_error",
+ "offset must be a non-negative integer.",
+ );
+ }
+ return offset;
+}
+
+export function mapGa4ReportError(error: unknown): never {
+ if (error instanceof Ga4ReportError) throw error;
+ if (error instanceof Ga4TokenError) {
+ throw new Ga4ReportError(
+ "ga4_reconnect_required",
+ "The Google Analytics connection has expired or was revoked.",
+ );
+ }
+ if (error instanceof Ga4MalformedResponseError) {
+ throw new Ga4ReportError(
+ "ga4_malformed_response",
+ "Google Analytics returned an invalid report.",
+ );
+ }
+ if (error instanceof Ga4DataApiError && error.status === 400) {
+ throw new Ga4ReportError(
+ "ga4_report_incompatible",
+ "This report is not compatible with the selected Analytics property.",
+ );
+ }
+ if (error instanceof Ga4DataApiError || error instanceof Ga4AdminApiError) {
+ if (error.status === 401) {
+ throw new Ga4ReportError(
+ "ga4_reconnect_required",
+ "The Google Analytics connection has expired or was revoked.",
+ );
+ }
+ if (error.status === 403) {
+ if (
+ error instanceof Ga4DataApiError &&
+ error.upstreamReason === "SERVICE_DISABLED"
+ ) {
+ throw new Ga4ReportError(
+ "ga4_upstream_unavailable",
+ "The Google Analytics Data API is not enabled for this OAuth application.",
+ );
+ }
+ throw new Ga4ReportError(
+ "ga4_property_inaccessible",
+ "The connected Google account can no longer access this property.",
+ );
+ }
+ if (error.status === 404) {
+ throw new Ga4ReportError(
+ "ga4_property_inaccessible",
+ "The selected Google Analytics property is no longer available.",
+ );
+ }
+ if (error.status === 429) {
+ throw new Ga4ReportError(
+ "ga4_quota_exhausted",
+ "Google Analytics reporting quota is exhausted. Try again later.",
+ error instanceof Ga4DataApiError ? error.retryAfterSeconds : null,
+ );
+ }
+ throw new Ga4ReportError(
+ "ga4_upstream_unavailable",
+ "Google Analytics reporting is temporarily unavailable.",
+ );
+ }
+ if (error instanceof z.ZodError) {
+ throw new Ga4ReportError(
+ "ga4_malformed_response",
+ "Google Analytics returned an invalid report.",
+ );
+ }
+ throw error;
+}
+
+async function resolveReportPage(input: {
+ client: ReturnType;
+ normalized: ReturnType;
+ request: Parameters["runReport"]>[0];
+ fetchCompleteReport: boolean;
+ offset: number;
+ limit: number;
+}) {
+ const { client, normalized, request, fetchCompleteReport, offset, limit } =
+ input;
+ const requestedPageIsBuffered =
+ normalized.totalRowCount <= normalized.rows.length ||
+ offset + limit <= normalized.rows.length;
+ let rows = fetchCompleteReport
+ ? normalized.rows.slice(offset, offset + limit)
+ : normalized.rows;
+
+ if (fetchCompleteReport && !requestedPageIsBuffered) {
+ const pageRequest = {
+ ...request,
+ offset: String(offset),
+ limit: String(limit),
+ };
+ const pageResponse = await client.runReport(pageRequest);
+ rows = normalizeGa4Response(pageResponse, pageRequest).rows;
+ }
+
+ const rowCount = rows.length;
+ if (rowCount === 0 && offset < normalized.totalRowCount) {
+ throw new Ga4MalformedResponseError();
+ }
+ const nextOffset = offset + rowCount;
+ return {
+ rows,
+ rowCount,
+ hasMore: nextOffset < normalized.totalRowCount,
+ nextOffset,
+ };
+}
+
+async function runReport(input: Ga4ReportInput, opts: { now?: Date } = {}) {
+ const connection = await Ga4ConnectionRepository.getByProjectId(
+ input.projectId,
+ );
+ if (!connection) {
+ throw new Ga4ReportError(
+ "ga4_not_connected",
+ "Google Analytics is not connected for this project.",
+ );
+ }
+ const limit = normalizeLimit(input.limit);
+ const offset = normalizeOffset(input.offset);
+ const channel = input.channel ?? "organic_search";
+ const dateRange = resolveGa4DateRange(
+ input,
+ connection.propertyTimeZone,
+ opts.now,
+ );
+ if (input.comparePreviousPeriod && !supportsComparison(input)) {
+ throw new Ga4ReportError(
+ "validation_error",
+ "Previous-period comparison is only available for event key events, channel-group acquisition, device audiences, and new-versus-returning audiences.",
+ );
+ }
+ const fetchCompleteReport = needsCompleteReport(input);
+ const reportConfiguration = getGa4ReportConfiguration({
+ ...input,
+ ...dateRange.resolvedDateRange,
+ channel,
+ limit,
+ offset,
+ });
+ const request = buildGa4ReportRequest({
+ ...input,
+ ...dateRange.resolvedDateRange,
+ channel,
+ limit: fetchCompleteReport ? COMPLETE_REPORT_LIMIT : limit,
+ offset: fetchCompleteReport ? 0 : offset,
+ });
+
+ try {
+ const client = createGa4DataClient({
+ userId: connection.connectedByUserId,
+ ga4AccountId: connection.ga4AccountId,
+ propertyId: connection.propertyId,
+ });
+ const previousDateRange = input.comparePreviousPeriod
+ ? previousPeriod(dateRange.resolvedDateRange)
+ : null;
+ const previousRequest = previousDateRange
+ ? buildGa4ReportRequest({
+ ...input,
+ ...previousDateRange,
+ channel,
+ limit: COMPLETE_REPORT_LIMIT,
+ offset: 0,
+ })
+ : null;
+ const [response, previousResponse] = await Promise.all([
+ client.runReport(request),
+ previousRequest ? client.runReport(previousRequest) : null,
+ ]);
+ const normalized = normalizeGa4Response(response, request);
+ const previousNormalized =
+ previousResponse && previousRequest
+ ? normalizeGa4Response(previousResponse, previousRequest)
+ : null;
+ const { rows, rowCount, hasMore, nextOffset } = await resolveReportPage({
+ client,
+ normalized,
+ request,
+ fetchCompleteReport,
+ offset,
+ limit,
+ });
+ const comparison =
+ previousNormalized && previousDateRange
+ ? buildReportComparison({
+ current: normalized,
+ previous: previousNormalized,
+ previousDateRange,
+ dimensions: reportConfiguration.dimensions,
+ metrics: reportConfiguration.metrics,
+ })
+ : undefined;
+ const comparisonIncomplete = comparison && !comparison.coverage.complete;
+ const enhancements = buildReportSpecificEnhancements(
+ normalized,
+ input,
+ dateRange.resolvedDateRange,
+ );
+ return {
+ status: "ok",
+ source: {
+ provider: "google_analytics",
+ propertyId: connection.propertyId,
+ propertyDisplayName: connection.propertyDisplayName,
+ },
+ request: {
+ requestedDateRange: dateRange.requestedDateRange,
+ resolvedDateRange: dateRange.resolvedDateRange,
+ propertyTimeZone: connection.propertyTimeZone,
+ currencyCode: connection.propertyCurrencyCode,
+ channel,
+ ...reportConfiguration,
+ limit,
+ offset,
+ },
+ rowCount,
+ totalRowCount: normalized.totalRowCount,
+ rows,
+ pageInfo: {
+ offset,
+ limit,
+ hasMore,
+ nextOffset: hasMore ? nextOffset : null,
+ },
+ reportMetadata: normalized.reportMetadata,
+ quota: normalized.quota,
+ warnings: [
+ ...dateRange.warnings,
+ ...(comparisonIncomplete ? ["comparison_incomplete"] : []),
+ ],
+ ...enhancements,
+ comparison,
+ };
+ } catch (error) {
+ mapGa4ReportError(error);
+ }
+}
+
+export const Ga4ReportingService = { runReport };
+export type Ga4ReportResult = Awaited>;
diff --git a/src/server/features/ga4/services/Ga4Service.test.ts b/src/server/features/ga4/services/Ga4Service.test.ts
new file mode 100644
index 0000000..bbfe48b
--- /dev/null
+++ b/src/server/features/ga4/services/Ga4Service.test.ts
@@ -0,0 +1,244 @@
+import type { SQL } from "drizzle-orm";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { Ga4AdminApiError, Ga4TokenError } from "@/server/lib/ga4Errors";
+import { Ga4Service } from "./Ga4Service";
+
+const mocks = vi.hoisted(() => {
+ const state: { grants: Array<{ id: string; accountId: string }> } = {
+ grants: [],
+ };
+ const listProperties = vi.fn();
+ const getProperty = vi.fn();
+ const getUserInfoEmail = vi.fn();
+ const deleteWhere = vi
+ .fn<(condition: SQL) => Promise>()
+ .mockResolvedValue(undefined);
+ return {
+ state,
+ listProperties,
+ getProperty,
+ getUserInfoEmail,
+ createGa4AdminClient: vi.fn(() => ({
+ listProperties,
+ getProperty,
+ getUserInfoEmail,
+ })),
+ dbSelect: vi.fn(() => ({
+ from: vi.fn(() => ({
+ where: vi.fn(() => {
+ const rows = state.grants;
+ return Object.assign(Promise.resolve(rows), {
+ limit: vi.fn().mockResolvedValue(rows),
+ });
+ }),
+ })),
+ })),
+ dbDelete: vi.fn(() => ({ where: deleteWhere })),
+ deleteWhere,
+ upsert: vi.fn(),
+ getByProjectId: vi.fn(),
+ deleteByProjectId: vi.fn(),
+ existsForConnectorAccount: vi.fn(),
+ };
+});
+
+vi.mock("cloudflare:workers", () => ({ env: {} }));
+vi.mock("@/db", () => ({
+ db: { select: mocks.dbSelect, delete: mocks.dbDelete },
+}));
+vi.mock("@/server/lib/ga4Client", () => ({
+ createGa4AdminClient: mocks.createGa4AdminClient,
+}));
+vi.mock("@/server/features/ga4/repositories/Ga4ConnectionRepository", () => ({
+ Ga4ConnectionRepository: {
+ upsert: mocks.upsert,
+ getByProjectId: mocks.getByProjectId,
+ deleteByProjectId: mocks.deleteByProjectId,
+ existsForConnectorAccount: mocks.existsForConnectorAccount,
+ },
+}));
+
+function collectSqlParams(value: unknown): unknown[] {
+ if (!value || typeof value !== "object") return [];
+ if ("value" in value && "encoder" in value) return [value.value];
+ if (!("queryChunks" in value) || !Array.isArray(value.queryChunks)) return [];
+ return value.queryChunks.flatMap(collectSqlParams);
+}
+
+describe("Ga4Service", () => {
+ beforeEach(() => {
+ mocks.state.grants = [{ id: "grant-a", accountId: "sub-a" }];
+ mocks.deleteByProjectId.mockResolvedValue(undefined);
+ });
+
+ it("verifies a freshly discovered property before persisting metadata", async () => {
+ mocks.listProperties.mockResolvedValue([
+ {
+ propertyId: "properties/11",
+ displayName: "Site A",
+ accountDisplayName: "Agency",
+ },
+ ]);
+ mocks.getProperty.mockResolvedValue({
+ name: "properties/11",
+ displayName: "Site A",
+ timeZone: "America/New_York",
+ currencyCode: "USD",
+ });
+ mocks.getUserInfoEmail.mockResolvedValue("client@example.com");
+ mocks.upsert.mockResolvedValue({ propertyId: "properties/11" });
+
+ await Ga4Service.setProperty({
+ projectId: "p1",
+ organizationId: "org1",
+ propertyId: "properties/11",
+ accountId: "sub-a",
+ userId: "u1",
+ });
+
+ expect(mocks.upsert).toHaveBeenCalledWith({
+ projectId: "p1",
+ organizationId: "org1",
+ propertyId: "properties/11",
+ propertyDisplayName: "Site A",
+ propertyTimeZone: "America/New_York",
+ propertyCurrencyCode: "USD",
+ connectedByUserId: "u1",
+ ga4AccountId: "sub-a",
+ connectedAccountEmail: "client@example.com",
+ });
+ });
+
+ it("passes a null email through when userinfo fails on an account switch", async () => {
+ mocks.state.grants = [{ id: "grant-b", accountId: "sub-b" }];
+ mocks.listProperties.mockResolvedValue([
+ {
+ propertyId: "properties/22",
+ displayName: "Site B",
+ accountDisplayName: "Client",
+ },
+ ]);
+ mocks.getProperty.mockResolvedValue({
+ name: "properties/22",
+ displayName: "Site B",
+ timeZone: "America/Los_Angeles",
+ currencyCode: "USD",
+ });
+ mocks.getUserInfoEmail.mockRejectedValue(new Error("userinfo unavailable"));
+ mocks.upsert.mockResolvedValue({ propertyId: "properties/22" });
+
+ await Ga4Service.setProperty({
+ projectId: "p1",
+ organizationId: "org1",
+ propertyId: "properties/22",
+ accountId: "sub-b",
+ userId: "u2",
+ });
+
+ expect(mocks.upsert).toHaveBeenCalledWith(
+ expect.objectContaining({
+ connectedByUserId: "u2",
+ ga4AccountId: "sub-b",
+ connectedAccountEmail: null,
+ }),
+ );
+ });
+
+ it("rejects a property or connector the current user does not own", async () => {
+ await expect(
+ Ga4Service.setProperty({
+ projectId: "p1",
+ organizationId: "org1",
+ propertyId: "properties/11",
+ accountId: "foreign-sub",
+ userId: "u1",
+ }),
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
+
+ mocks.listProperties.mockResolvedValue([]);
+ await expect(
+ Ga4Service.setProperty({
+ projectId: "p1",
+ organizationId: "org1",
+ propertyId: "properties/11",
+ accountId: "sub-a",
+ userId: "u1",
+ }),
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
+ expect(mocks.upsert).not.toHaveBeenCalled();
+ });
+
+ it("distinguishes expired grants from inaccessible property discovery", async () => {
+ mocks.state.grants = [
+ { id: "grant-a", accountId: "sub-a" },
+ { id: "grant-b", accountId: "sub-b" },
+ ];
+ mocks.listProperties
+ .mockRejectedValueOnce(new Ga4TokenError("revoked"))
+ .mockRejectedValueOnce(new Ga4AdminApiError(403, "forbidden"));
+ const consoleError = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => undefined);
+
+ await expect(
+ Ga4Service.listPropertiesForUserWithGrantStatus("u1"),
+ ).resolves.toEqual({
+ accounts: [
+ {
+ accountId: "sub-a",
+ email: null,
+ requiresReconnect: true,
+ propertiesUnavailable: false,
+ properties: [],
+ },
+ {
+ accountId: "sub-b",
+ email: null,
+ requiresReconnect: false,
+ propertiesUnavailable: true,
+ properties: [],
+ },
+ ],
+ });
+ expect(consoleError).toHaveBeenCalledTimes(1);
+ expect(consoleError).toHaveBeenCalledWith("ga4.property_discovery_failed", {
+ errorName: "Ga4AdminApiError",
+ status: 403,
+ });
+ consoleError.mockRestore();
+ });
+
+ it("removes the caller's unused Analytics grant on disconnect", async () => {
+ mocks.getByProjectId.mockResolvedValue({
+ connectedByUserId: "u1",
+ ga4AccountId: "sub-a",
+ });
+ mocks.existsForConnectorAccount.mockResolvedValue(false);
+
+ await Ga4Service.disconnect({ projectId: "p1", userId: "u1" });
+
+ expect(mocks.deleteByProjectId).toHaveBeenCalledWith("p1");
+ const whereCondition = mocks.deleteWhere.mock.calls[0]?.[0];
+ expect(collectSqlParams(whereCondition)).toEqual(
+ expect.arrayContaining(["u1", "google-analytics", "sub-a"]),
+ );
+ });
+
+ it("keeps a shared grant and never unlinks another member's grant", async () => {
+ mocks.getByProjectId.mockResolvedValue({
+ connectedByUserId: "u1",
+ ga4AccountId: "sub-a",
+ });
+ mocks.existsForConnectorAccount.mockResolvedValue(true);
+ await Ga4Service.disconnect({ projectId: "p1", userId: "u1" });
+ expect(mocks.dbDelete).not.toHaveBeenCalled();
+
+ mocks.getByProjectId.mockResolvedValue({
+ connectedByUserId: "owner",
+ ga4AccountId: "sub-a",
+ });
+ await Ga4Service.disconnect({ projectId: "p2", userId: "other-member" });
+ expect(mocks.existsForConnectorAccount).toHaveBeenCalledTimes(1);
+ expect(mocks.dbDelete).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/server/features/ga4/services/Ga4Service.ts b/src/server/features/ga4/services/Ga4Service.ts
new file mode 100644
index 0000000..190325d
--- /dev/null
+++ b/src/server/features/ga4/services/Ga4Service.ts
@@ -0,0 +1,179 @@
+import { and, eq } from "drizzle-orm";
+import { db } from "@/db";
+import { account } from "@/db/schema";
+import { AppError } from "@/server/lib/errors";
+import { createGa4AdminClient } from "@/server/lib/ga4Client";
+import { Ga4AdminApiError, Ga4TokenError } from "@/server/lib/ga4Errors";
+import { GA4_OAUTH_PROVIDER_ID } from "@/shared/ga4";
+import {
+ Ga4ConnectionRepository,
+ type Ga4Connection,
+} from "@/server/features/ga4/repositories/Ga4ConnectionRepository";
+
+async function getConnection(projectId: string): Promise {
+ return Ga4ConnectionRepository.getByProjectId(projectId);
+}
+
+async function listGrantsForUser(userId: string) {
+ return db
+ .select({ id: account.id, accountId: account.accountId })
+ .from(account)
+ .where(
+ and(
+ eq(account.userId, userId),
+ eq(account.providerId, GA4_OAUTH_PROVIDER_ID),
+ ),
+ );
+}
+
+async function userHasGrant(userId: string): Promise {
+ const grants = await listGrantsForUser(userId);
+ return grants.length > 0;
+}
+
+function requiresReconnect(error: unknown): boolean {
+ return (
+ error instanceof Ga4TokenError ||
+ (error instanceof Ga4AdminApiError && error.status === 401)
+ );
+}
+
+async function listPropertiesForUserWithGrantStatus(userId: string) {
+ const grants = await listGrantsForUser(userId);
+ const accounts = await Promise.all(
+ grants.map(async (grant) => {
+ const client = createGa4AdminClient({
+ userId,
+ ga4AccountId: grant.accountId,
+ });
+ try {
+ const properties = await client.listProperties();
+ let email: string | null = null;
+ try {
+ email = await client.getUserInfoEmail();
+ } catch {
+ email = null;
+ }
+ return {
+ accountId: grant.accountId,
+ email,
+ requiresReconnect: false,
+ propertiesUnavailable: false,
+ properties,
+ };
+ } catch (error) {
+ const reconnect = requiresReconnect(error);
+ if (!reconnect) {
+ console.error("ga4.property_discovery_failed", {
+ errorName: error instanceof Error ? error.name : "UnknownError",
+ status:
+ error instanceof Ga4AdminApiError ? error.status : undefined,
+ });
+ }
+ return {
+ accountId: grant.accountId,
+ email: null,
+ requiresReconnect: reconnect,
+ propertiesUnavailable: !reconnect,
+ properties: [],
+ };
+ }
+ }),
+ );
+ return { accounts };
+}
+
+async function setProperty(input: {
+ projectId: string;
+ organizationId: string;
+ propertyId: string;
+ accountId: string;
+ userId: string;
+}): Promise {
+ const grants = await listGrantsForUser(input.userId);
+ if (!grants.some((grant) => grant.accountId === input.accountId)) {
+ throw new AppError(
+ "NOT_FOUND",
+ "That Google account isn't connected to your OpenSEO account.",
+ );
+ }
+
+ const client = createGa4AdminClient({
+ userId: input.userId,
+ ga4AccountId: input.accountId,
+ });
+ const properties = await client.listProperties();
+ if (
+ !properties.some((property) => property.propertyId === input.propertyId)
+ ) {
+ throw new AppError(
+ "NOT_FOUND",
+ "That Google Analytics property isn't available on your connected Google account.",
+ );
+ }
+
+ const property = await client.getProperty(input.propertyId);
+ let connectedAccountEmail: string | null = null;
+ try {
+ connectedAccountEmail = await client.getUserInfoEmail();
+ } catch {
+ connectedAccountEmail = null;
+ }
+
+ return Ga4ConnectionRepository.upsert({
+ projectId: input.projectId,
+ organizationId: input.organizationId,
+ propertyId: property.name,
+ propertyDisplayName: property.displayName,
+ propertyTimeZone: property.timeZone,
+ propertyCurrencyCode: property.currencyCode,
+ connectedByUserId: input.userId,
+ ga4AccountId: input.accountId,
+ connectedAccountEmail,
+ });
+}
+
+async function unlinkUserGrant(
+ userId: string,
+ ga4AccountId: string,
+): Promise {
+ await db
+ .delete(account)
+ .where(
+ and(
+ eq(account.userId, userId),
+ eq(account.providerId, GA4_OAUTH_PROVIDER_ID),
+ eq(account.accountId, ga4AccountId),
+ ),
+ );
+}
+
+async function disconnect(input: {
+ projectId: string;
+ userId: string;
+}): Promise {
+ const connection = await Ga4ConnectionRepository.getByProjectId(
+ input.projectId,
+ );
+ await Ga4ConnectionRepository.deleteByProjectId(input.projectId);
+ if (
+ connection?.ga4AccountId &&
+ connection.connectedByUserId === input.userId
+ ) {
+ const stillUsed = await Ga4ConnectionRepository.existsForConnectorAccount(
+ input.userId,
+ connection.ga4AccountId,
+ );
+ if (!stillUsed) {
+ await unlinkUserGrant(input.userId, connection.ga4AccountId);
+ }
+ }
+}
+
+export const Ga4Service = {
+ getConnection,
+ userHasGrant,
+ listPropertiesForUserWithGrantStatus,
+ setProperty,
+ disconnect,
+};
diff --git a/src/server/features/ga4/services/SearchOpportunityService.test.ts b/src/server/features/ga4/services/SearchOpportunityService.test.ts
new file mode 100644
index 0000000..4795e6d
--- /dev/null
+++ b/src/server/features/ga4/services/SearchOpportunityService.test.ts
@@ -0,0 +1,238 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { GscNotConnectedError } from "@/server/lib/gscErrors";
+import { makeGa4ReportResult } from "./ga4-test-fixtures";
+import { SearchOpportunityService } from "./SearchOpportunityService";
+
+const mocks = vi.hoisted(() => ({
+ getGa4Connection: vi.fn(),
+ getGscConnection: vi.fn(),
+ getPerformance: vi.fn(),
+ runGa4Report: vi.fn(),
+}));
+
+vi.mock("@/server/features/ga4/repositories/Ga4ConnectionRepository", () => ({
+ Ga4ConnectionRepository: { getByProjectId: mocks.getGa4Connection },
+}));
+vi.mock("@/server/features/gsc/services/GscService", () => ({
+ GscService: {
+ getConnection: mocks.getGscConnection,
+ getPerformance: mocks.getPerformance,
+ },
+}));
+vi.mock("@/server/features/ga4/services/Ga4ReportingService", () => ({
+ Ga4ReportingService: { runReport: mocks.runGa4Report },
+ resolveGa4DateRange: vi.fn(),
+}));
+
+const ga4Result = makeGa4ReportResult({
+ status: "ok" as const,
+ source: {
+ provider: "google_analytics" as const,
+ propertyId: "properties/123",
+ propertyDisplayName: "Example",
+ },
+ request: {
+ requestedDateRange: { startDate: "2026-07-07", endDate: "2026-08-03" },
+ resolvedDateRange: { startDate: "2026-07-07", endDate: "2026-08-03" },
+ propertyTimeZone: "America/New_York",
+ currencyCode: "USD",
+ channel: "organic_search" as const,
+ limit: 1_000,
+ offset: 0,
+ },
+ rows: [
+ {
+ hostName: "example.com",
+ landingPage: "/High-Value/?utm_source=x",
+ sessions: 100,
+ activeUsers: 90,
+ engagedSessions: 80,
+ engagementRate: 0.8,
+ keyEvents: 10,
+ sessionKeyEventRate: 0.1,
+ transactions: 2,
+ purchaseRevenue: 500,
+ },
+ {
+ hostName: "example.com",
+ landingPage: "/other/",
+ sessions: 10,
+ activeUsers: 9,
+ engagedSessions: 5,
+ engagementRate: 0.5,
+ keyEvents: 1,
+ sessionKeyEventRate: 0.02,
+ transactions: 0,
+ purchaseRevenue: 0,
+ },
+ ],
+ rowCount: 2,
+ totalRowCount: 2,
+ pageInfo: { offset: 0, limit: 1_000, hasMore: false, nextOffset: null },
+ reportMetadata: {
+ dataLossFromOtherRow: false,
+ subjectToThresholding: false,
+ sampling: [],
+ restrictedMetrics: [],
+ emptyReason: null,
+ hasLimitedData: false,
+ },
+ quota: null,
+ warnings: [],
+});
+
+describe("SearchOpportunityService", () => {
+ beforeEach(() => {
+ mocks.getGa4Connection.mockResolvedValue({
+ propertyTimeZone: "America/New_York",
+ });
+ mocks.getGscConnection.mockResolvedValue({
+ siteUrl: "https://example.com/",
+ });
+ mocks.runGa4Report.mockResolvedValue(ga4Result);
+ });
+
+ it("normalizes URLs, scores joined candidates, and leaves unmatched pages unscored", async () => {
+ mocks.getPerformance.mockResolvedValue({
+ siteUrl: "https://example.com/",
+ request: {},
+ rows: [
+ {
+ keys: ["https://EXAMPLE.com/High-Value/?ref=gsc"],
+ clicks: 10,
+ impressions: 1_000,
+ ctr: 0.01,
+ position: 6,
+ },
+ {
+ keys: ["https://example.com/other"],
+ clicks: 5,
+ impressions: 500,
+ ctr: 0.01,
+ position: 12,
+ },
+ {
+ keys: ["https://example.com/no-analytics"],
+ clicks: 1,
+ impressions: 2_000,
+ ctr: 0.0005,
+ position: 8,
+ },
+ {
+ keys: ["https://example.com/top-result"],
+ clicks: 100,
+ impressions: 3_000,
+ ctr: 0.03,
+ position: 2,
+ },
+ ],
+ });
+ const result = await SearchOpportunityService.getOpportunities(
+ { projectId: "project_1" },
+ { now: new Date("2026-08-06T12:00:00Z") },
+ );
+
+ expect(mocks.getPerformance).toHaveBeenCalledWith(
+ expect.objectContaining({
+ startDate: "2026-07-07",
+ endDate: "2026-08-03",
+ dimensions: ["page"],
+ rowLimit: 1_000,
+ }),
+ );
+ expect(mocks.runGa4Report).toHaveBeenCalledWith(
+ expect.objectContaining({
+ startDate: "2026-07-07",
+ endDate: "2026-08-03",
+ kind: "landing_pages",
+ }),
+ );
+ expect(result.totalCandidateRows).toBe(3);
+ expect(result.coverage).toMatchObject({
+ matchedRows: 2,
+ unmatchedGscRows: 1,
+ });
+ expect(result.rows[0]).toMatchObject({
+ page: "https://EXAMPLE.com/High-Value/?ref=gsc",
+ normalizedPage: "example.com/High-Value",
+ joinStatus: "joined",
+ score: 100,
+ });
+ expect(
+ result.rows.find((row) => row.joinStatus === "gsc_only"),
+ ).toMatchObject({
+ ga4: null,
+ score: null,
+ scoreComponents: null,
+ });
+ expect(result.scoring.businessValueMetric).toBe("sessionKeyEventRate");
+ expect(result.warnings).toContain("source_time_zones_differ");
+ });
+
+ it("uses engagement rate when all joined rows have zero key events", async () => {
+ mocks.getPerformance.mockResolvedValue({
+ siteUrl: "https://example.com/",
+ request: {},
+ rows: [
+ {
+ keys: ["https://example.com/other"],
+ clicks: 1,
+ impressions: 100,
+ ctr: 0.01,
+ position: 10,
+ },
+ ],
+ });
+ mocks.runGa4Report.mockResolvedValue({
+ ...ga4Result,
+ rows: [{ ...ga4Result.rows[1], keyEvents: 0, sessionKeyEventRate: 0 }],
+ rowCount: 1,
+ totalRowCount: 1,
+ });
+ const result = await SearchOpportunityService.getOpportunities({
+ projectId: "project_1",
+ });
+ expect(result.scoring).toMatchObject({
+ engagementFallback: true,
+ businessValueMetric: "engagementRate",
+ });
+ });
+
+ it("anchors the shared default range to the GA4 property date", async () => {
+ mocks.getGa4Connection.mockResolvedValue({
+ propertyTimeZone: "America/Los_Angeles",
+ });
+ mocks.getPerformance.mockResolvedValue({
+ siteUrl: "https://example.com/",
+ request: {},
+ rows: [],
+ });
+
+ await SearchOpportunityService.getOpportunities(
+ { projectId: "project_1" },
+ { now: new Date("2026-08-06T01:00:00Z") },
+ );
+
+ expect(mocks.getPerformance).toHaveBeenCalledWith(
+ expect.objectContaining({
+ startDate: "2026-07-06",
+ endDate: "2026-08-02",
+ }),
+ );
+ expect(mocks.runGa4Report).toHaveBeenCalledWith(
+ expect.objectContaining({
+ startDate: "2026-07-06",
+ endDate: "2026-08-02",
+ }),
+ );
+ });
+
+ it("fails before querying GA4 when Search Console is not connected", async () => {
+ mocks.getGscConnection.mockResolvedValue(null);
+ await expect(
+ SearchOpportunityService.getOpportunities({ projectId: "project_1" }),
+ ).rejects.toBeInstanceOf(GscNotConnectedError);
+ expect(mocks.getPerformance).not.toHaveBeenCalled();
+ expect(mocks.runGa4Report).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/server/features/ga4/services/SearchOpportunityService.ts b/src/server/features/ga4/services/SearchOpportunityService.ts
new file mode 100644
index 0000000..32849fc
--- /dev/null
+++ b/src/server/features/ga4/services/SearchOpportunityService.ts
@@ -0,0 +1,293 @@
+import { GscService } from "@/server/features/gsc/services/GscService";
+import { GscNotConnectedError } from "@/server/lib/gscErrors";
+import {
+ Ga4ReportingService,
+ resolveGa4DateRange,
+} from "@/server/features/ga4/services/Ga4ReportingService";
+import { Ga4ReportError } from "@/server/lib/ga4Errors";
+import { Ga4ConnectionRepository } from "@/server/features/ga4/repositories/Ga4ConnectionRepository";
+import { ga4DateInTimeZone, shiftGa4Date } from "./Ga4Dates";
+
+type SearchOpportunityInput = {
+ projectId: string;
+ startDate?: string;
+ endDate?: string;
+ limit?: number;
+};
+
+type Candidate = {
+ page: string;
+ normalizedPage: string | null;
+ clicks: number;
+ impressions: number;
+ ctr: number;
+ position: number;
+ joinStatus: "joined" | "gsc_only";
+ ga4: {
+ sessions: number;
+ activeUsers: number;
+ engagedSessions: number;
+ engagementRate: number;
+ keyEvents: number;
+ sessionKeyEventRate: number;
+ transactions: number;
+ purchaseRevenue: number | null;
+ } | null;
+ score: number | null;
+ scoreComponents: {
+ demand: number;
+ businessValue: number;
+ reachability: number;
+ } | null;
+};
+
+function resolveCombinedDates(
+ input: Pick,
+ propertyTimeZone: string,
+ now: Date,
+) {
+ if (!input.startDate && !input.endDate) {
+ const endDate = shiftGa4Date(ga4DateInTimeZone(now, propertyTimeZone), -3);
+ return {
+ startDate: shiftGa4Date(endDate, -27),
+ endDate,
+ };
+ }
+ return resolveGa4DateRange(input, propertyTimeZone, now).resolvedDateRange;
+}
+
+function normalizePageKey(value: string): string | null {
+ const trimmed = value.trim();
+ if (!trimmed || trimmed === "(not set)") return null;
+ try {
+ const url = new URL(
+ trimmed.includes("://") ? trimmed : `https://${trimmed}`,
+ );
+ let host = url.hostname.toLowerCase();
+ const defaultPort =
+ (url.protocol === "http:" && url.port === "80") ||
+ (url.protocol === "https:" && url.port === "443");
+ if (url.port && !defaultPort) host += `:${url.port}`;
+ let path = url.pathname || "/";
+ if (path.length > 1) path = path.replace(/\/+$/, "");
+ return `${host}${path}`;
+ } catch {
+ return null;
+ }
+}
+
+function numberField(
+ row: Record,
+ name: string,
+): number {
+ const value = row[name];
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
+}
+
+function percentileRanks(values: number[]): number[] {
+ if (values.length === 0) return [];
+ if (values.length === 1) return [1];
+ return values.map((value) => {
+ const lower = values.filter((candidate) => candidate < value).length;
+ return lower / (values.length - 1);
+ });
+}
+
+function roundComponent(value: number): number {
+ return Math.round(value * 10_000) / 10_000;
+}
+
+async function getOpportunities(
+ input: SearchOpportunityInput,
+ opts: { now?: Date } = {},
+) {
+ const limit = input.limit ?? 50;
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
+ throw new Ga4ReportError(
+ "validation_error",
+ "limit must be an integer from 1 to 100.",
+ );
+ }
+ const [ga4Connection, gscConnection] = await Promise.all([
+ Ga4ConnectionRepository.getByProjectId(input.projectId),
+ GscService.getConnection(input.projectId),
+ ]);
+ if (!ga4Connection) {
+ throw new Ga4ReportError(
+ "ga4_not_connected",
+ "Google Analytics is not connected for this project.",
+ );
+ }
+ if (!gscConnection) throw new GscNotConnectedError(input.projectId);
+
+ const now = opts.now ?? new Date();
+ const dates = resolveCombinedDates(
+ input,
+ ga4Connection.propertyTimeZone,
+ now,
+ );
+ const gsc = await GscService.getPerformance({
+ projectId: input.projectId,
+ dimensions: ["page"],
+ startDate: dates.startDate,
+ endDate: dates.endDate,
+ rowLimit: 1_000,
+ startRow: 0,
+ type: "web",
+ dataState: "final",
+ });
+ const ga4 = await Ga4ReportingService.runReport({
+ projectId: input.projectId,
+ kind: "landing_pages",
+ startDate: dates.startDate,
+ endDate: dates.endDate,
+ limit: 1_000,
+ offset: 0,
+ channel: "organic_search",
+ });
+
+ const ga4ByPage = new Map>();
+ let invalidGa4Rows = 0;
+ for (const row of ga4.rows) {
+ const host = typeof row.hostName === "string" ? row.hostName : "";
+ const landing = typeof row.landingPage === "string" ? row.landingPage : "";
+ const key = normalizePageKey(`${host}${landing}`);
+ if (!key) {
+ invalidGa4Rows += 1;
+ continue;
+ }
+ ga4ByPage.set(key, row);
+ }
+
+ const candidates: Candidate[] = gsc.rows
+ .filter((row) => row.position >= 4 && row.position <= 20)
+ .map((row) => {
+ const page = row.keys?.[0] ?? "";
+ const normalizedPage = normalizePageKey(page);
+ const analytics = normalizedPage
+ ? ga4ByPage.get(normalizedPage)
+ : undefined;
+ return {
+ page,
+ normalizedPage,
+ clicks: row.clicks,
+ impressions: row.impressions,
+ ctr: row.ctr,
+ position: row.position,
+ joinStatus: analytics ? "joined" : "gsc_only",
+ ga4: analytics
+ ? {
+ sessions: numberField(analytics, "sessions"),
+ activeUsers: numberField(analytics, "activeUsers"),
+ engagedSessions: numberField(analytics, "engagedSessions"),
+ engagementRate: numberField(analytics, "engagementRate"),
+ keyEvents: numberField(analytics, "keyEvents"),
+ sessionKeyEventRate: numberField(
+ analytics,
+ "sessionKeyEventRate",
+ ),
+ transactions: numberField(analytics, "transactions"),
+ purchaseRevenue:
+ typeof analytics.purchaseRevenue === "number"
+ ? analytics.purchaseRevenue
+ : null,
+ }
+ : null,
+ score: null,
+ scoreComponents: null,
+ } satisfies Candidate;
+ });
+
+ const joined = candidates.filter(
+ (
+ candidate,
+ ): candidate is Candidate & { ga4: NonNullable } =>
+ candidate.ga4 !== null,
+ );
+ const engagementFallback =
+ joined.length > 0 &&
+ joined.every((candidate) => candidate.ga4.keyEvents === 0);
+ const demand = percentileRanks(
+ joined.map((candidate) => Math.log1p(candidate.impressions)),
+ );
+ const businessValue = percentileRanks(
+ joined.map((candidate) =>
+ engagementFallback
+ ? candidate.ga4.engagementRate
+ : candidate.ga4.sessionKeyEventRate,
+ ),
+ );
+ const reachability = percentileRanks(
+ joined.map((candidate) => 20 - candidate.position),
+ );
+ joined.forEach((candidate, index) => {
+ const components = {
+ demand: roundComponent(demand[index] ?? 0),
+ businessValue: roundComponent(businessValue[index] ?? 0),
+ reachability: roundComponent(reachability[index] ?? 0),
+ };
+ candidate.scoreComponents = components;
+ candidate.score = Math.round(
+ 100 *
+ (0.5 * components.demand +
+ 0.3 * components.businessValue +
+ 0.2 * components.reachability),
+ );
+ });
+ candidates.sort((a, b) => {
+ if (a.score == null && b.score != null) return 1;
+ if (a.score != null && b.score == null) return -1;
+ return (b.score ?? 0) - (a.score ?? 0) || b.impressions - a.impressions;
+ });
+
+ const matchedRows = joined.length;
+ const unmatchedGscRows = candidates.length - matchedRows;
+ const returned = candidates.slice(0, limit);
+ return {
+ status: "ok" as const,
+ source: {
+ searchConsoleSiteUrl: gsc.siteUrl,
+ googleAnalyticsPropertyId: ga4.source.propertyId,
+ googleAnalyticsPropertyDisplayName: ga4.source.propertyDisplayName,
+ },
+ request: {
+ dateRange: dates,
+ limit,
+ searchConsoleTimeZone: "America/Los_Angeles",
+ googleAnalyticsTimeZone: ga4.request.propertyTimeZone,
+ },
+ rowCount: returned.length,
+ totalCandidateRows: candidates.length,
+ rows: returned,
+ scoring: {
+ formula:
+ "round(100 * (0.5 * demand + 0.3 * businessValue + 0.2 * reachability))",
+ businessValueMetric: engagementFallback
+ ? "engagementRate"
+ : "sessionKeyEventRate",
+ engagementFallback,
+ scoreDataLimited: ga4.reportMetadata.hasLimitedData,
+ },
+ coverage: {
+ gscRowsConsidered: gsc.rows.length,
+ ga4RowsConsidered: ga4.rows.length,
+ matchedRows,
+ unmatchedGscRows,
+ unmatchedGa4Rows:
+ Math.max(ga4ByPage.size - matchedRows, 0) + invalidGa4Rows,
+ },
+ truncated: {
+ gsc: gsc.rows.length >= 1_000,
+ ga4: ga4.totalRowCount > ga4.rows.length,
+ candidates: returned.length < candidates.length,
+ },
+ warnings:
+ ga4.request.propertyTimeZone === "America/Los_Angeles"
+ ? ga4.warnings
+ : [...ga4.warnings, "source_time_zones_differ"],
+ reportMetadata: ga4.reportMetadata,
+ quota: ga4.quota,
+ };
+}
+
+export const SearchOpportunityService = { getOpportunities };
diff --git a/src/server/features/ga4/services/ga4-test-fixtures.ts b/src/server/features/ga4/services/ga4-test-fixtures.ts
new file mode 100644
index 0000000..bf46fc7
--- /dev/null
+++ b/src/server/features/ga4/services/ga4-test-fixtures.ts
@@ -0,0 +1,88 @@
+import type { Ga4Connection } from "@/server/features/ga4/repositories/Ga4ConnectionRepository";
+import type { Ga4ReportResult } from "./Ga4ReportingService";
+
+export function makeGa4Connection(
+ overrides: Partial = {},
+): Ga4Connection {
+ return {
+ id: "ga4_connection_1",
+ projectId: "project_1",
+ organizationId: "org_123",
+ propertyId: "properties/123",
+ propertyDisplayName: "Example",
+ propertyTimeZone: "America/New_York",
+ propertyCurrencyCode: "USD",
+ connectedByUserId: "user_1",
+ ga4AccountId: "account_1",
+ connectedAccountEmail: "alice@example.com",
+ createdAt: "2026-08-01T00:00:00.000Z",
+ updatedAt: "2026-08-01T00:00:00.000Z",
+ ...overrides,
+ };
+}
+
+type Ga4ReportResultOverrides = Omit<
+ Partial,
+ "source" | "request" | "pageInfo" | "reportMetadata"
+> & {
+ source?: Partial;
+ request?: Partial;
+ pageInfo?: Partial;
+ reportMetadata?: Partial;
+};
+
+export function makeGa4ReportResult(
+ overrides: Ga4ReportResultOverrides = {},
+): Ga4ReportResult {
+ const { source, request, pageInfo, reportMetadata, ...topLevelOverrides } =
+ overrides;
+ const result = {
+ status: "ok",
+ source: {
+ provider: "google_analytics",
+ propertyId: "properties/123",
+ propertyDisplayName: "Example",
+ },
+ request: {
+ requestedDateRange: null,
+ resolvedDateRange: { startDate: "2026-07-09", endDate: "2026-08-05" },
+ propertyTimeZone: "America/New_York",
+ currencyCode: "USD",
+ channel: "organic_search",
+ reportKind: "landing_pages",
+ breakdown: "landing_page",
+ dimensions: ["hostName", "landingPage"],
+ metrics: ["sessions"],
+ flags: { includeDate: false, onlyWithTransactions: false },
+ limit: 100,
+ offset: 0,
+ },
+ rowCount: 0,
+ totalRowCount: 0,
+ rows: [],
+ pageInfo: { offset: 0, limit: 100, hasMore: false, nextOffset: null },
+ reportMetadata: {
+ dataLossFromOtherRow: false,
+ subjectToThresholding: false,
+ sampling: [],
+ restrictedMetrics: [],
+ emptyReason: null,
+ hasLimitedData: false,
+ },
+ quota: null,
+ warnings: [],
+ diagnostics: [],
+ comparison: undefined,
+ };
+ const merged = {
+ ...result,
+ ...topLevelOverrides,
+ source: { ...result.source, ...source },
+ request: { ...result.request, ...request },
+ pageInfo: { ...result.pageInfo, ...pageInfo },
+ reportMetadata: { ...result.reportMetadata, ...reportMetadata },
+ };
+ // Tests intentionally override rows from several report-kind variants.
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
+ return merged as Ga4ReportResult;
+}
diff --git a/src/server/features/google/oauth-config.ts b/src/server/features/google/oauth-config.ts
new file mode 100644
index 0000000..85fede9
--- /dev/null
+++ b/src/server/features/google/oauth-config.ts
@@ -0,0 +1,25 @@
+import { getOptionalEnvValue } from "@/server/lib/runtime-env";
+import { MIN_BETTER_AUTH_SECRET_LENGTH } from "@/shared/selfhost-checks";
+
+type GoogleOAuthClientConfig = {
+ clientId: string;
+ clientSecret: string;
+};
+
+export async function getGoogleOAuthClientConfig(): Promise {
+ const clientId = (await getOptionalEnvValue("GOOGLE_CLIENT_ID"))?.trim();
+ const clientSecret = (
+ await getOptionalEnvValue("GOOGLE_CLIENT_SECRET")
+ )?.trim();
+ return clientId && clientSecret ? { clientId, clientSecret } : null;
+}
+
+export async function hasSelfHostedGoogleOAuthConfig(
+ config?: GoogleOAuthClientConfig | null,
+): Promise {
+ const oauthConfig =
+ config === undefined ? await getGoogleOAuthClientConfig() : config;
+ if (!oauthConfig) return false;
+ const secret = (await getOptionalEnvValue("BETTER_AUTH_SECRET"))?.trim();
+ return Boolean(secret && secret.length >= MIN_BETTER_AUTH_SECRET_LENGTH);
+}
diff --git a/src/server/features/google/selfHostedOAuth.test.ts b/src/server/features/google/selfHostedOAuth.test.ts
new file mode 100644
index 0000000..8918d15
--- /dev/null
+++ b/src/server/features/google/selfHostedOAuth.test.ts
@@ -0,0 +1,235 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ createSelfHostedGoogleAuthorizationUrl,
+ GA4_INTEGRATION,
+ GSC_INTEGRATION,
+ handleSelfHostedGoogleOAuthCallback,
+ type SelfHostedGoogleOAuthIntegration,
+} from "./selfHostedOAuth";
+
+const mocks = vi.hoisted(() => ({
+ getGoogleOAuthClientConfig: vi.fn(),
+ hasSelfHostedGoogleOAuthConfig: vi.fn(),
+ fetch: vi.fn(),
+ selectLimit: vi.fn(),
+ insertValues: vi.fn(),
+ updateSet: vi.fn(),
+ getAuth: vi.fn(),
+}));
+
+vi.mock("cloudflare:workers", () => ({ env: {} }));
+vi.mock("drizzle-orm", () => ({
+ and: (...values: unknown[]) => values,
+ eq: (...values: unknown[]) => values,
+}));
+vi.mock("@/db/schema", () => ({
+ account: {
+ id: "id",
+ userId: "userId",
+ providerId: "providerId",
+ accountId: "accountId",
+ },
+}));
+vi.mock("@/db", () => ({
+ db: {
+ select: () => ({
+ from: () => ({ where: () => ({ limit: mocks.selectLimit }) }),
+ }),
+ insert: () => ({ values: mocks.insertValues }),
+ update: () => ({
+ set: mocks.updateSet.mockReturnValue({ where: vi.fn() }),
+ }),
+ },
+}));
+vi.mock("@/lib/auth", () => ({ getAuth: mocks.getAuth }));
+vi.mock("@/server/features/google/oauth-config", () => ({
+ getGoogleOAuthClientConfig: mocks.getGoogleOAuthClientConfig,
+ hasSelfHostedGoogleOAuthConfig: mocks.hasSelfHostedGoogleOAuthConfig,
+}));
+
+const user = { userId: "user-1", userEmail: "user@example.com" };
+const publicOrigin = "http://localhost:3001";
+const callbackURL = `${publicOrigin}/p/project/settings`;
+
+async function authorizationState(
+ integration: SelfHostedGoogleOAuthIntegration,
+) {
+ const url = new URL(
+ await createSelfHostedGoogleAuthorizationUrl({
+ integration,
+ user,
+ callbackURL,
+ publicOrigin,
+ }),
+ );
+ return url.searchParams.get("state")!;
+}
+
+function callbackRequest(
+ integration: SelfHostedGoogleOAuthIntegration,
+ state: string,
+ params: Record,
+) {
+ const url = new URL(integration.callbackPath, publicOrigin);
+ url.searchParams.set("state", state);
+ for (const [key, value] of Object.entries(params)) {
+ url.searchParams.set(key, value);
+ }
+ return new Request(url);
+}
+
+describe("self-hosted Google OAuth providers", () => {
+ beforeEach(() => {
+ mocks.getGoogleOAuthClientConfig.mockResolvedValue({
+ clientId: "google-client-id",
+ clientSecret: "google-client-secret",
+ });
+ mocks.hasSelfHostedGoogleOAuthConfig.mockResolvedValue(true);
+ mocks.selectLimit.mockResolvedValue([]);
+ mocks.insertValues.mockResolvedValue(undefined);
+ mocks.getAuth.mockReturnValue({
+ $context: Promise.resolve({
+ options: { account: { encryptOAuthTokens: false } },
+ secretConfig: "secret",
+ }),
+ });
+ vi.stubGlobal("fetch", mocks.fetch);
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("keeps GSC and GA4 callback paths and scopes isolated", async () => {
+ const common = { user, callbackURL, publicOrigin };
+ const gscUrl = new URL(
+ await createSelfHostedGoogleAuthorizationUrl({
+ integration: GSC_INTEGRATION,
+ ...common,
+ }),
+ );
+ const ga4Url = new URL(
+ await createSelfHostedGoogleAuthorizationUrl({
+ integration: GA4_INTEGRATION,
+ ...common,
+ }),
+ );
+
+ expect(gscUrl.searchParams.get("redirect_uri")).toBe(
+ `${publicOrigin}/api/gsc/oauth/callback`,
+ );
+ expect(ga4Url.searchParams.get("redirect_uri")).toBe(
+ `${publicOrigin}/api/ga4/oauth/callback`,
+ );
+ expect(gscUrl.searchParams.get("scope")).toContain("webmasters.readonly");
+ expect(ga4Url.searchParams.get("scope")).toContain("analytics.readonly");
+ expect(gscUrl.searchParams.get("state")).not.toBe(
+ ga4Url.searchParams.get("state"),
+ );
+ });
+
+ it("round-trips signed state, exchanges the code, and persists the GA4 grant", async () => {
+ const state = await authorizationState(GA4_INTEGRATION);
+ const idToken = `header.${btoa(JSON.stringify({ sub: "google-account-1" }))}.signature`;
+ mocks.fetch.mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ access_token: "access-token",
+ refresh_token: "refresh-token",
+ expires_in: 3600,
+ scope: "openid analytics.readonly",
+ id_token: idToken,
+ }),
+ { status: 200 },
+ ),
+ );
+
+ const response = await handleSelfHostedGoogleOAuthCallback({
+ integration: GA4_INTEGRATION,
+ request: callbackRequest(GA4_INTEGRATION, state, { code: "code-1" }),
+ user,
+ publicOrigin,
+ });
+
+ expect(response.status).toBe(303);
+ expect(response.headers.get("Location")).toBe("/p/project/settings");
+ expect(mocks.fetch).toHaveBeenCalledWith(
+ "https://oauth2.googleapis.com/token",
+ expect.objectContaining({ method: "POST" }),
+ );
+ expect(mocks.insertValues).toHaveBeenCalledWith(
+ expect.objectContaining({
+ accountId: "google-account-1",
+ providerId: "google-analytics",
+ userId: "user-1",
+ accessToken: "access-token",
+ refreshToken: "refresh-token",
+ }),
+ );
+ });
+
+ it.each(["tampered", "expired"])(
+ "rejects %s state before token exchange",
+ async (kind) => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-08-07T12:00:00Z"));
+ let state = await authorizationState(GA4_INTEGRATION);
+ if (kind === "tampered") state = `${state.slice(0, -1)}x`;
+ else vi.setSystemTime(new Date("2026-08-07T12:11:00Z"));
+
+ await expect(
+ handleSelfHostedGoogleOAuthCallback({
+ integration: GA4_INTEGRATION,
+ request: callbackRequest(GA4_INTEGRATION, state, { code: "code-1" }),
+ user,
+ publicOrigin,
+ }),
+ ).rejects.toMatchObject({ code: "VALIDATION_ERROR" });
+ expect(mocks.fetch).not.toHaveBeenCalled();
+ expect(mocks.insertValues).not.toHaveBeenCalled();
+ },
+ );
+
+ it("handles a provider denial without exchanging or persisting credentials", async () => {
+ const state = await authorizationState(GA4_INTEGRATION);
+ const response = await handleSelfHostedGoogleOAuthCallback({
+ integration: GA4_INTEGRATION,
+ request: callbackRequest(GA4_INTEGRATION, state, {
+ error: "access_denied",
+ }),
+ user,
+ publicOrigin,
+ });
+
+ expect(response.status).toBe(303);
+ expect(mocks.fetch).not.toHaveBeenCalled();
+ expect(mocks.insertValues).not.toHaveBeenCalled();
+ });
+
+ it("round-trips the GSC integration through the shared callback", async () => {
+ const state = await authorizationState(GSC_INTEGRATION);
+ const idToken = `header.${btoa(JSON.stringify({ sub: "gsc-account-1" }))}.signature`;
+ mocks.fetch.mockResolvedValue(
+ new Response(
+ JSON.stringify({ access_token: "gsc-token", id_token: idToken }),
+ { status: 200 },
+ ),
+ );
+
+ const response = await handleSelfHostedGoogleOAuthCallback({
+ integration: GSC_INTEGRATION,
+ request: callbackRequest(GSC_INTEGRATION, state, { code: "gsc-code" }),
+ user,
+ publicOrigin,
+ });
+
+ expect(response.status).toBe(303);
+ expect(mocks.insertValues).toHaveBeenCalledWith(
+ expect.objectContaining({
+ providerId: "google-search-console",
+ accountId: "gsc-account-1",
+ accessToken: "gsc-token",
+ }),
+ );
+ });
+});
diff --git a/src/server/features/gsc/selfHostedOAuth.ts b/src/server/features/google/selfHostedOAuth.ts
similarity index 52%
rename from src/server/features/gsc/selfHostedOAuth.ts
rename to src/server/features/google/selfHostedOAuth.ts
index 45a5166..817e288 100644
--- a/src/server/features/gsc/selfHostedOAuth.ts
+++ b/src/server/features/google/selfHostedOAuth.ts
@@ -1,25 +1,58 @@
import { symmetricEncrypt } from "better-auth/crypto";
+import { env } from "cloudflare:workers";
import { and, eq } from "drizzle-orm";
import { decodeJwt } from "jose";
import { z } from "zod";
import { db } from "@/db";
import { account } from "@/db/schema";
import { getAuth } from "@/lib/auth";
+import { getAuthMode, isHostedAuthMode } from "@/lib/auth-mode";
+import { resolveCloudflareAccessContext } from "@/middleware/ensure-user/cloudflareAccess";
+import { resolveLocalNoAuthContext } from "@/middleware/ensure-user/delegated";
import { AppError } from "@/server/lib/errors";
+import { responseForAppError } from "@/server/lib/http-errors";
+import { getPublicOrigin } from "@/server/mcp/public-origin";
+import { GA4_OAUTH_PROVIDER_ID, GA4_OAUTH_SCOPES } from "@/shared/ga4";
import { GSC_OAUTH_PROVIDER_ID, GSC_OAUTH_SCOPES } from "@/shared/gsc";
import {
- getGscOAuthClientConfig,
- hasSelfHostedGscConfig,
+ getGoogleOAuthClientConfig,
+ hasSelfHostedGoogleOAuthConfig,
} from "./oauth-config";
const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
-type SelfHostedGscUser = {
+export type SelfHostedGoogleOAuthIntegration = {
+ providerId: string;
+ stateNamespace: string;
+ displayName: string;
+ callbackPath: `/${string}`;
+ scopes: readonly string[];
+};
+
+type SelfHostedGoogleUser = {
userId: string;
userEmail: string;
};
+export const GSC_INTEGRATION: SelfHostedGoogleOAuthIntegration = {
+ providerId: GSC_OAUTH_PROVIDER_ID,
+ // Preserve the state-signing namespace used by the original GSC flow so a
+ // deployment does not invalidate an authorization already in progress.
+ stateNamespace: "gsc",
+ displayName: "Search Console",
+ callbackPath: "/api/gsc/oauth/callback",
+ scopes: GSC_OAUTH_SCOPES,
+};
+
+export const GA4_INTEGRATION: SelfHostedGoogleOAuthIntegration = {
+ providerId: GA4_OAUTH_PROVIDER_ID,
+ stateNamespace: "ga4",
+ displayName: "Google Analytics",
+ callbackPath: "/api/ga4/oauth/callback",
+ scopes: GA4_OAUTH_SCOPES,
+};
+
const oauthStateSchema = z.object({
userId: z.string().min(1),
callbackPath: z.string().min(1),
@@ -35,17 +68,12 @@ const googleTokenResponseSchema = z.object({
token_type: z.string().optional(),
});
-const googleIdTokenSchema = z.object({
- sub: z.string().min(1),
-});
-
+const googleIdTokenSchema = z.object({ sub: z.string().min(1) });
type GoogleTokenResponse = z.infer;
function bytesToBase64Url(bytes: Uint8Array) {
let binary = "";
- for (const byte of bytes) {
- binary += String.fromCharCode(byte);
- }
+ for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary)
.replaceAll("+", "-")
.replaceAll("/", "_")
@@ -58,20 +86,24 @@ function base64UrlToBytes(value: string) {
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
}
-async function getStateKey(clientSecret: string) {
+async function getStateKey(clientSecret: string, stateNamespace: string) {
return crypto.subtle.importKey(
"raw",
- new TextEncoder().encode(`openseo:gsc:${clientSecret}`),
+ new TextEncoder().encode(`openseo:${stateNamespace}:${clientSecret}`),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"],
);
}
-async function signState(payload: string, clientSecret: string) {
+async function signState(
+ payload: string,
+ clientSecret: string,
+ stateNamespace: string,
+) {
const signature = await crypto.subtle.sign(
"HMAC",
- await getStateKey(clientSecret),
+ await getStateKey(clientSecret, stateNamespace),
new TextEncoder().encode(payload),
);
return bytesToBase64Url(new Uint8Array(signature));
@@ -88,6 +120,7 @@ function getSafeCallbackPath(callbackURL: string, publicOrigin: string) {
}
async function createState(input: {
+ integration: SelfHostedGoogleOAuthIntegration;
clientSecret: string;
userId: string;
callbackURL: string;
@@ -105,107 +138,109 @@ async function createState(input: {
}),
),
);
- const signature = await signState(payload, input.clientSecret);
+ const signature = await signState(
+ payload,
+ input.clientSecret,
+ input.integration.stateNamespace,
+ );
return `${payload}.${signature}`;
}
-async function verifyState(state: string, clientSecret: string) {
- const [payload, signature] = state.split(".");
+async function verifyState(input: {
+ state: string;
+ clientSecret: string;
+ integration: SelfHostedGoogleOAuthIntegration;
+}) {
+ const [payload, signature] = input.state.split(".");
if (!payload || !signature) {
- throw new AppError("VALIDATION_ERROR", "Invalid Search Console state");
+ throw new AppError(
+ "VALIDATION_ERROR",
+ `Invalid ${input.integration.displayName} state`,
+ );
}
-
const ok = await crypto.subtle.verify(
"HMAC",
- await getStateKey(clientSecret),
+ await getStateKey(input.clientSecret, input.integration.stateNamespace),
base64UrlToBytes(signature),
new TextEncoder().encode(payload),
);
if (!ok) {
- throw new AppError("VALIDATION_ERROR", "Invalid Search Console state");
+ throw new AppError(
+ "VALIDATION_ERROR",
+ `Invalid ${input.integration.displayName} state`,
+ );
}
-
const parsed = oauthStateSchema.parse(
JSON.parse(new TextDecoder().decode(base64UrlToBytes(payload))),
);
if (parsed.exp < Date.now()) {
- throw new AppError("VALIDATION_ERROR", "Expired Search Console state");
+ throw new AppError(
+ "VALIDATION_ERROR",
+ `Expired ${input.integration.displayName} state`,
+ );
}
-
return parsed;
}
-function getRedirectUri(publicOrigin: string) {
- return `${publicOrigin}/api/gsc/oauth/callback`;
-}
-
-function accessTokenExpiresAt(tokens: GoogleTokenResponse) {
- return new Date(Date.now() + (tokens.expires_in ?? 3600) * 1_000);
-}
-
-function storedScope(tokens: GoogleTokenResponse) {
- return tokens.scope
- ? tokens.scope.trim().split(/\s+/).join(",")
- : GSC_OAUTH_SCOPES.join(",");
+function getRedirectUri(
+ publicOrigin: string,
+ integration: SelfHostedGoogleOAuthIntegration,
+) {
+ return `${publicOrigin}${integration.callbackPath}`;
}
function getGoogleAccountId(tokens: GoogleTokenResponse) {
if (!tokens.id_token) {
throw new AppError(
"VALIDATION_ERROR",
- "Google did not return an ID token for Search Console.",
+ "Google did not return an ID token.",
);
}
-
return googleIdTokenSchema.parse(decodeJwt(tokens.id_token)).sub;
}
async function upsertGrant(input: {
- user: SelfHostedGscUser;
+ integration: SelfHostedGoogleOAuthIntegration;
+ user: SelfHostedGoogleUser;
tokens: GoogleTokenResponse;
}) {
- // Encrypt tokens at rest exactly the way Better Auth's setTokenUtil does
- // (same key from BETTER_AUTH_SECRET, same crypto, same encryptOAuthTokens
- // gate), so getAccessToken decrypts them on read — and so flipping the flag
- // can never desync the write and read paths.
const ctx = await getAuth().$context;
const encrypt = (value: string) =>
ctx.options.account?.encryptOAuthTokens
? symmetricEncrypt({ key: ctx.secretConfig, data: value })
: value;
const googleAccountId = getGoogleAccountId(input.tokens);
-
const existing = await db
.select({ id: account.id, refreshToken: account.refreshToken })
.from(account)
.where(
and(
eq(account.userId, input.user.userId),
- eq(account.providerId, GSC_OAUTH_PROVIDER_ID),
+ eq(account.providerId, input.integration.providerId),
eq(account.accountId, googleAccountId),
),
)
.limit(1);
-
const accountValues = {
accountId: googleAccountId,
- providerId: GSC_OAUTH_PROVIDER_ID,
+ providerId: input.integration.providerId,
userId: input.user.userId,
accessToken: await encrypt(input.tokens.access_token),
- // A fresh refresh token is encrypted here; an absent one falls back to the
- // already-encrypted value stored on the existing grant.
refreshToken: input.tokens.refresh_token
? await encrypt(input.tokens.refresh_token)
: (existing[0]?.refreshToken ?? null),
idToken: input.tokens.id_token
? await encrypt(input.tokens.id_token)
: null,
- accessTokenExpiresAt: accessTokenExpiresAt(input.tokens),
+ accessTokenExpiresAt: new Date(
+ Date.now() + (input.tokens.expires_in ?? 3600) * 1_000,
+ ),
refreshTokenExpiresAt: null,
- scope: storedScope(input.tokens),
+ scope: input.tokens.scope
+ ? input.tokens.scope.trim().split(/\s+/).join(",")
+ : input.integration.scopes.join(","),
password: null,
};
-
if (existing[0]) {
await db
.update(account)
@@ -213,7 +248,6 @@ async function upsertGrant(input: {
.where(eq(account.id, existing[0].id));
return;
}
-
await db.insert(account).values({
id: crypto.randomUUID(),
...accountValues,
@@ -223,6 +257,7 @@ async function upsertGrant(input: {
}
async function exchangeCode(input: {
+ integration: SelfHostedGoogleOAuthIntegration;
code: string;
clientId: string;
clientSecret: string;
@@ -239,32 +274,31 @@ async function exchangeCode(input: {
grant_type: "authorization_code",
}),
});
-
if (!response.ok) {
throw new AppError(
"VALIDATION_ERROR",
- "Google rejected the Search Console authorization code.",
+ `Google rejected the ${input.integration.displayName} authorization code.`,
);
}
-
return googleTokenResponseSchema.parse(await response.json());
}
-export async function createSelfHostedGscAuthorizationUrl(input: {
- user: SelfHostedGscUser;
+export async function createSelfHostedGoogleAuthorizationUrl(input: {
+ integration: SelfHostedGoogleOAuthIntegration;
+ user: SelfHostedGoogleUser;
callbackURL: string;
publicOrigin: string;
}) {
- const config = await getGscOAuthClientConfig();
- if (!config || !(await hasSelfHostedGscConfig())) {
+ const config = await getGoogleOAuthClientConfig();
+ if (!config || !(await hasSelfHostedGoogleOAuthConfig(config))) {
throw new AppError(
"AUTH_CONFIG_MISSING",
- "Search Console is not configured. Set GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and BETTER_AUTH_SECRET.",
+ `${input.integration.displayName} is not configured. Set GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and BETTER_AUTH_SECRET.`,
);
}
-
- const redirectUri = getRedirectUri(input.publicOrigin);
+ const redirectUri = getRedirectUri(input.publicOrigin, input.integration);
const state = await createState({
+ integration: input.integration,
clientSecret: config.clientSecret,
userId: input.user.userId,
callbackURL: input.callbackURL,
@@ -274,63 +308,102 @@ export async function createSelfHostedGscAuthorizationUrl(input: {
url.searchParams.set("client_id", config.clientId);
url.searchParams.set("redirect_uri", redirectUri);
url.searchParams.set("response_type", "code");
- url.searchParams.set("scope", GSC_OAUTH_SCOPES.join(" "));
+ url.searchParams.set("scope", input.integration.scopes.join(" "));
url.searchParams.set("access_type", "offline");
url.searchParams.set("prompt", "select_account consent");
url.searchParams.set("state", state);
-
return url.toString();
}
-export async function handleSelfHostedGscOAuthCallback(input: {
+export async function handleSelfHostedGoogleOAuthCallback(input: {
+ integration: SelfHostedGoogleOAuthIntegration;
request: Request;
- user: SelfHostedGscUser;
+ user: SelfHostedGoogleUser;
publicOrigin: string;
}) {
- const config = await getGscOAuthClientConfig();
+ const config = await getGoogleOAuthClientConfig();
if (!config) {
- return new Response("Missing Google Search Console OAuth configuration", {
- status: 500,
- });
+ return new Response(
+ `Missing ${input.integration.displayName} OAuth configuration`,
+ { status: 500 },
+ );
}
-
const url = new URL(input.request.url);
const stateParam = url.searchParams.get("state");
if (!stateParam) {
- return new Response("Missing Search Console OAuth state", { status: 400 });
+ return new Response(
+ `Missing ${input.integration.displayName} OAuth state`,
+ {
+ status: 400,
+ },
+ );
}
-
- const state = await verifyState(stateParam, config.clientSecret);
+ const state = await verifyState({
+ state: stateParam,
+ clientSecret: config.clientSecret,
+ integration: input.integration,
+ });
if (state.userId !== input.user.userId) {
- return new Response("Search Console OAuth user mismatch", { status: 403 });
+ return new Response(
+ `${input.integration.displayName} OAuth user mismatch`,
+ {
+ status: 403,
+ },
+ );
}
-
- // state.callbackPath is a validated same-origin relative path
- // (getSafeCallbackPath). Redirect with a *relative* Location so the browser
- // resolves it against the real request origin — this avoids trusting
- // x-forwarded-host for the final hop.
const redirectToCallback = () =>
new Response(null, {
status: 303,
headers: { Location: state.callbackPath },
});
-
- if (url.searchParams.get("error")) {
- return redirectToCallback();
- }
-
+ if (url.searchParams.get("error")) return redirectToCallback();
const code = url.searchParams.get("code");
if (!code) {
- return new Response("Missing Search Console OAuth code", { status: 400 });
+ return new Response(`Missing ${input.integration.displayName} OAuth code`, {
+ status: 400,
+ });
}
-
const tokens = await exchangeCode({
+ integration: input.integration,
code,
clientId: config.clientId,
clientSecret: config.clientSecret,
- redirectUri: getRedirectUri(input.publicOrigin),
+ redirectUri: getRedirectUri(input.publicOrigin, input.integration),
+ });
+ await upsertGrant({
+ integration: input.integration,
+ user: input.user,
+ tokens,
});
- await upsertGrant({ user: input.user, tokens });
-
return redirectToCallback();
}
+
+export async function handleSelfHostedGoogleOAuthCallbackRequest(
+ request: Request,
+ integration: SelfHostedGoogleOAuthIntegration,
+) {
+ try {
+ const authMode = getAuthMode(env.AUTH_MODE);
+ if (isHostedAuthMode(authMode)) {
+ return new Response("Not found", { status: 404 });
+ }
+ const context =
+ authMode === "local_noauth"
+ ? await resolveLocalNoAuthContext()
+ : await resolveCloudflareAccessContext(request.headers);
+ return await handleSelfHostedGoogleOAuthCallback({
+ integration,
+ request,
+ user: {
+ userId: context.userId,
+ userEmail: context.userEmail,
+ },
+ publicOrigin: getPublicOrigin(request),
+ });
+ } catch (error) {
+ return responseForAppError(
+ error,
+ `${integration.displayName} OAuth failed`,
+ );
+ }
+}
diff --git a/src/server/features/gsc/oauth-config.ts b/src/server/features/gsc/oauth-config.ts
deleted file mode 100644
index 09c8ee4..0000000
--- a/src/server/features/gsc/oauth-config.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { getOptionalEnvValue } from "@/server/lib/runtime-env";
-import { MIN_BETTER_AUTH_SECRET_LENGTH } from "@/shared/selfhost-checks";
-
-type GscOAuthClientConfig = {
- clientId: string;
- clientSecret: string;
-};
-
-export async function getGscOAuthClientConfig(): Promise {
- const clientId = (await getOptionalEnvValue("GOOGLE_CLIENT_ID"))?.trim();
- const clientSecret = (
- await getOptionalEnvValue("GOOGLE_CLIENT_SECRET")
- )?.trim();
-
- if (!clientId || !clientSecret) return null;
-
- return { clientId, clientSecret };
-}
-
-// Self-hosted Search Console needs the Google OAuth client AND BETTER_AUTH_SECRET
-// (>=32 chars): the secret keys OAuth-token encryption and lets us build the
-// Better Auth instance that mints/refreshes tokens. Both must be set before we
-// surface the connect flow.
-export async function hasSelfHostedGscConfig(): Promise {
- if (!(await getGscOAuthClientConfig())) return false;
-
- const secret = (await getOptionalEnvValue("BETTER_AUTH_SECRET"))?.trim();
- return Boolean(secret && secret.length >= MIN_BETTER_AUTH_SECRET_LENGTH);
-}
diff --git a/src/server/features/gsc/services/GscService.test.ts b/src/server/features/gsc/services/GscService.test.ts
index 7419e43..5243620 100644
--- a/src/server/features/gsc/services/GscService.test.ts
+++ b/src/server/features/gsc/services/GscService.test.ts
@@ -1,25 +1,10 @@
/* eslint-disable max-lines */
import type { SQL } from "drizzle-orm";
import { beforeEach, describe, expect, it, vi } from "vitest";
+import { GscApiError, GscTokenError } from "@/server/lib/gscErrors";
+import { GscService } from "./GscService";
const mocks = vi.hoisted(() => {
- class GscApiError extends Error {
- constructor(
- public readonly status: number,
- message: string,
- ) {
- super(message);
- this.name = "GscApiError";
- }
- }
-
- class GscTokenError extends Error {
- constructor(message = "token unavailable") {
- super(message);
- this.name = "GscTokenError";
- }
- }
-
const state: { selectRows: Array<{ id: string; accountId: string }> } = {
selectRows: [],
};
@@ -61,8 +46,6 @@ const mocks = vi.hoisted(() => {
getByProjectId: vi.fn(),
deleteByProjectId: vi.fn(),
existsForConnectorAccount: vi.fn(),
- GscApiError,
- GscTokenError,
};
});
@@ -72,8 +55,6 @@ vi.mock("@/db", () => ({
}));
vi.mock("@/server/lib/gscClient", () => ({
createGscClient: mocks.createGscClient,
- GscApiError: mocks.GscApiError,
- GscTokenError: mocks.GscTokenError,
}));
vi.mock("@/server/features/gsc/repositories/GscConnectionRepository", () => ({
GscConnectionRepository: {
@@ -115,7 +96,6 @@ describe("GscService.setSite", () => {
]);
mocks.getUserInfoEmail.mockResolvedValue("client@example.com");
mocks.upsert.mockResolvedValue({ siteUrl: "https://x/" });
- const { GscService } = await import("./GscService");
await GscService.setSite({ ...baseInput, siteUrl: "https://x/" });
@@ -143,7 +123,6 @@ describe("GscService.setSite", () => {
siteUrl: "https://x/",
connectedAccountEmail: "previous@example.com",
});
- const { GscService } = await import("./GscService");
const result = await GscService.setSite({
...baseInput,
@@ -159,8 +138,6 @@ describe("GscService.setSite", () => {
});
it("rejects a Google sub that is not one of the caller's grants", async () => {
- const { GscService } = await import("./GscService");
-
await expect(
GscService.setSite({
...baseInput,
@@ -176,7 +153,6 @@ describe("GscService.setSite", () => {
mocks.listSites.mockResolvedValue([
{ siteUrl: "https://x/", permissionLevel: "siteUnverifiedUser" },
]);
- const { GscService } = await import("./GscService");
await expect(
GscService.setSite({ ...baseInput, siteUrl: "https://x/" }),
@@ -188,7 +164,6 @@ describe("GscService.setSite", () => {
mocks.listSites.mockResolvedValue([
{ siteUrl: "https://x/", permissionLevel: "siteOwner" },
]);
- const { GscService } = await import("./GscService");
await expect(
GscService.setSite({ ...baseInput, siteUrl: "https://not-mine/" }),
@@ -216,11 +191,10 @@ describe("GscService.listSitesForUserWithGrantStatus", () => {
);
mocks.listSites.mockImplementation(
async ({ gscAccountId }: { gscAccountId?: string }) => {
- if (gscAccountId === "sub-b") throw new mocks.GscTokenError();
+ if (gscAccountId === "sub-b") throw new GscTokenError("revoked");
return [{ siteUrl: "https://x/", permissionLevel: "siteOwner" }];
},
);
- const { GscService } = await import("./GscService");
await expect(
GscService.listSitesForUserWithGrantStatus("u1"),
@@ -253,7 +227,6 @@ describe("GscService.listSitesForUserWithGrantStatus", () => {
mocks.listSites.mockResolvedValue([
{ siteUrl: "https://x/", permissionLevel: "siteOwner" },
]);
- const { GscService } = await import("./GscService");
await expect(
GscService.listSitesForUserWithGrantStatus("u1"),
@@ -273,9 +246,8 @@ describe("GscService.listSitesForUserWithGrantStatus", () => {
mocks.state.selectRows = [{ id: "grant-a", accountId: "sub-a" }];
mocks.getUserInfoEmail.mockResolvedValue("a@example.com");
mocks.listSites.mockRejectedValue(
- new mocks.GscApiError(403, "Search Console denied access"),
+ new GscApiError(403, "Search Console denied access"),
);
- const { GscService } = await import("./GscService");
await expect(
GscService.listSitesForUserWithGrantStatus("u1"),
@@ -298,7 +270,7 @@ describe("GscService.listSitesForUserWithGrantStatus", () => {
async ({ gscAccountId }: { gscAccountId?: string }) =>
`${gscAccountId}@example.com`,
);
- const rateLimit = new mocks.GscApiError(429, "slow down");
+ const rateLimit = new GscApiError(429, "slow down");
mocks.listSites.mockImplementation(
async ({ gscAccountId }: { gscAccountId?: string }) => {
if (gscAccountId === "sub-b") throw rateLimit;
@@ -308,7 +280,6 @@ describe("GscService.listSitesForUserWithGrantStatus", () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
- const { GscService } = await import("./GscService");
await expect(
GscService.listSitesForUserWithGrantStatus("u1"),
@@ -352,7 +323,6 @@ describe("GscService.getPerformance", () => {
gscAccountId: "sub-a",
siteUrl: "https://x/",
});
- const { GscService } = await import("./GscService");
await GscService.getPerformance({
projectId: "p1",
@@ -373,7 +343,6 @@ describe("GscService.getPerformance", () => {
gscAccountId: null,
siteUrl: "https://x/",
});
- const { GscService } = await import("./GscService");
await GscService.getPerformance({
projectId: "p1",
@@ -403,7 +372,6 @@ describe("GscService.disconnect", () => {
gscAccountId: "sub-b",
});
mocks.existsForConnectorAccount.mockResolvedValue(false);
- const { GscService } = await import("./GscService");
await GscService.disconnect({ projectId: "p1", userId: "u1" });
@@ -422,7 +390,6 @@ describe("GscService.disconnect", () => {
gscAccountId: "sub-b",
});
mocks.existsForConnectorAccount.mockResolvedValue(true);
- const { GscService } = await import("./GscService");
await GscService.disconnect({ projectId: "p1", userId: "u1" });
@@ -434,7 +401,6 @@ describe("GscService.disconnect", () => {
connectedByUserId: "owner",
gscAccountId: "sub-b",
});
- const { GscService } = await import("./GscService");
await GscService.disconnect({ projectId: "p1", userId: "other-member" });
@@ -447,7 +413,6 @@ describe("GscService.disconnect", () => {
connectedByUserId: "u1",
gscAccountId: null,
});
- const { GscService } = await import("./GscService");
await GscService.disconnect({ projectId: "p1", userId: "u1" });
@@ -458,7 +423,6 @@ describe("GscService.disconnect", () => {
it("deletes no grants when no property was bound", async () => {
mocks.getByProjectId.mockResolvedValue(null);
- const { GscService } = await import("./GscService");
await GscService.disconnect({ projectId: "p1", userId: "u1" });
diff --git a/src/server/features/gsc/services/GscService.ts b/src/server/features/gsc/services/GscService.ts
index 89db7ff..29e4c8c 100644
--- a/src/server/features/gsc/services/GscService.ts
+++ b/src/server/features/gsc/services/GscService.ts
@@ -5,11 +5,15 @@ import { GSC_OAUTH_PROVIDER_ID } from "@/shared/gsc";
import { AppError } from "@/server/lib/errors";
import {
createGscClient,
- GscApiError,
- GscTokenError,
type GscSite,
type UrlInspectionResult,
} from "@/server/lib/gscClient";
+import {
+ GscApiError,
+ GscNotConnectedError,
+ GscTokenError,
+} from "@/server/lib/gscErrors";
+export { GscNotConnectedError } from "@/server/lib/gscErrors";
import {
buildSearchAnalyticsRequest,
type GscPerformanceInput,
@@ -42,13 +46,6 @@ type GscSiteListResult = {
};
/** Thrown when a project has no connected GSC property. */
-export class GscNotConnectedError extends Error {
- constructor(public readonly projectId: string) {
- super("Search Console is not connected for this project");
- this.name = "GscNotConnectedError";
- }
-}
-
async function getConnection(projectId: string): Promise {
return GscConnectionRepository.getByProjectId(projectId);
}
diff --git a/src/server/features/rank-tracking/repositories/RankTrackingRepository.ts b/src/server/features/rank-tracking/repositories/RankTrackingRepository.ts
index 1616115..9e80522 100644
--- a/src/server/features/rank-tracking/repositories/RankTrackingRepository.ts
+++ b/src/server/features/rank-tracking/repositories/RankTrackingRepository.ts
@@ -135,7 +135,7 @@ async function getDueConfigsWithOrganization(nowIso: string) {
// ---------------------------------------------------------------------------
/**
- * Try to insert a new pending run. Returns true if inserted, false if blocked
+ * Try to insert a new pending run. Returns true when inserted, or false if blocked
* by the partial unique index on (config_id) WHERE status IN ('pending',
* 'running') — i.e. another active run exists for this config.
*
@@ -148,13 +148,13 @@ async function tryCreateRun(data: {
projectId: string;
keywordsTotal: number;
isSubsetRun?: boolean;
-}): Promise {
+}) {
const inserted = await db
.insert(rankCheckRuns)
.values({ ...data, status: "pending" })
.onConflictDoNothing()
.returning({ id: rankCheckRuns.id });
- return inserted.length > 0;
+ return Boolean(inserted[0]);
}
async function updateRun(
@@ -249,23 +249,48 @@ async function getKeywordsForConfig(configId: string) {
async function addKeywordsToConfig(
keywords: Array<{ id: string; configId: string; keyword: string }>,
) {
- await executeInBatches(keywords, (tx, kw) =>
- tx.insert(rankTrackingKeywords).values(kw).onConflictDoNothing(),
- );
+ const insertedIds: string[] = [];
+
+ // Keep each statement below D1's bound-parameter limit and return only rows
+ // that actually won the unique(config_id, keyword) race.
+ const insertBatchSize = 25;
+ for (let i = 0; i < keywords.length; i += insertBatchSize) {
+ const chunk = keywords.slice(i, i + insertBatchSize);
+ const inserted = await db
+ .insert(rankTrackingKeywords)
+ .values(chunk)
+ .onConflictDoNothing()
+ .returning({ id: rankTrackingKeywords.id });
+ insertedIds.push(...inserted.map((row) => row.id));
+ }
+
+ return insertedIds;
}
async function removeKeywordsFromConfig(
keywordIds: string[],
configId: string,
) {
- await db
- .delete(rankTrackingKeywords)
- .where(
- and(
- inArray(rankTrackingKeywords.id, keywordIds),
- eq(rankTrackingKeywords.configId, configId),
- ),
- );
+ if (keywordIds.length === 0) return [];
+
+ const removedIds: string[] = [];
+ // One extra bind is used by configId; keep each IN list below D1's ~100
+ // parameter ceiling while preserving the config ownership predicate.
+ const deleteBatchSize = 90;
+ for (let i = 0; i < keywordIds.length; i += deleteBatchSize) {
+ const chunk = keywordIds.slice(i, i + deleteBatchSize);
+ const removed = await db
+ .delete(rankTrackingKeywords)
+ .where(
+ and(
+ inArray(rankTrackingKeywords.id, chunk),
+ eq(rankTrackingKeywords.configId, configId),
+ ),
+ )
+ .returning({ id: rankTrackingKeywords.id });
+ removedIds.push(...removed.map((row) => row.id));
+ }
+ return removedIds;
}
async function getConfigSummaries(projectId: string) {
diff --git a/src/server/features/rank-tracking/services/RankTrackingKeywordService.ts b/src/server/features/rank-tracking/services/RankTrackingKeywordService.ts
new file mode 100644
index 0000000..b362b5b
--- /dev/null
+++ b/src/server/features/rank-tracking/services/RankTrackingKeywordService.ts
@@ -0,0 +1,191 @@
+import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
+import { AppError } from "@/server/lib/errors";
+import {
+ devicesCount,
+ estimateRankCheckCredits,
+ estimateScheduledRankCheckCredits,
+ isScheduledRankTrackingInterval,
+ MAX_KEYWORDS_PER_CONFIG,
+} from "@/shared/rank-tracking";
+
+async function addKeywords(
+ configId: string,
+ projectId: string,
+ keywords: string[],
+ approval:
+ | { kind: "direct_user_action" }
+ | {
+ kind: "credit_ceiling";
+ maxEstimatedScheduledCheckCredits?: number;
+ },
+) {
+ const config = await getValidatedConfig(configId, projectId);
+ const existing = await RankTrackingRepository.getKeywordsForConfig(configId);
+
+ if (existing.length >= MAX_KEYWORDS_PER_CONFIG) {
+ throw new AppError(
+ "INTERNAL_ERROR",
+ `Maximum ${MAX_KEYWORDS_PER_CONFIG} keywords per domain. Currently tracking ${existing.length}.`,
+ );
+ }
+
+ const existingKeywords = new Set(existing.map((kw) => kw.keyword));
+ const available = MAX_KEYWORDS_PER_CONFIG - existing.length;
+ const seen = new Set();
+ const rows: Array<{ id: string; configId: string; keyword: string }> = [];
+
+ for (const raw of keywords) {
+ if (rows.length >= available) break;
+ const normalized = raw.trim().toLowerCase();
+ if (
+ normalized &&
+ !seen.has(normalized) &&
+ !existingKeywords.has(normalized)
+ ) {
+ seen.add(normalized);
+ rows.push({ id: crypto.randomUUID(), configId, keyword: normalized });
+ }
+ }
+
+ const scheduleInterval = isScheduledRankTrackingInterval(
+ config.scheduleInterval,
+ )
+ ? config.scheduleInterval
+ : null;
+ let scheduledEstimate:
+ | ReturnType
+ | undefined;
+ if (rows.length > 0 && scheduleInterval) {
+ scheduledEstimate = estimateScheduledRankCheckCredits(
+ existing.length + rows.length,
+ config.devices,
+ config.serpDepth,
+ scheduleInterval,
+ );
+ if (
+ approval.kind === "credit_ceiling" &&
+ (approval.maxEstimatedScheduledCheckCredits == null ||
+ scheduledEstimate.costCredits >
+ approval.maxEstimatedScheduledCheckCredits)
+ ) {
+ throw scheduledApprovalError(scheduleInterval, scheduledEstimate);
+ }
+ }
+
+ const addedIds =
+ rows.length > 0
+ ? await RankTrackingRepository.addKeywordsToConfig(rows)
+ : [];
+
+ if (
+ scheduledEstimate &&
+ scheduleInterval &&
+ addedIds.length > 0 &&
+ approval.kind === "credit_ceiling"
+ ) {
+ const persistedKeywordCount =
+ await RankTrackingRepository.getKeywordCountForConfig(configId);
+ scheduledEstimate = estimateScheduledRankCheckCredits(
+ persistedKeywordCount,
+ config.devices,
+ config.serpDepth,
+ scheduleInterval,
+ );
+ if (
+ approval.maxEstimatedScheduledCheckCredits == null ||
+ scheduledEstimate.costCredits > approval.maxEstimatedScheduledCheckCredits
+ ) {
+ await RankTrackingRepository.removeKeywordsFromConfig(addedIds, configId);
+ throw scheduledApprovalError(scheduleInterval, scheduledEstimate);
+ }
+ }
+
+ return { added: addedIds.length, addedIds, scheduledEstimate };
+}
+
+async function removeKeywords(
+ configId: string,
+ projectId: string,
+ keywordIds: string[],
+) {
+ await getValidatedConfig(configId, projectId);
+ const uniqueIds = [...new Set(keywordIds)];
+ const removedIds = await RankTrackingRepository.removeKeywordsFromConfig(
+ uniqueIds,
+ configId,
+ );
+ return { removed: removedIds.length, removedIds };
+}
+
+async function estimateCost(
+ configId: string,
+ projectId: string,
+ additionalKeywordCount = 0,
+) {
+ const config = await getValidatedConfig(configId, projectId);
+ const existingKeywordCount =
+ await RankTrackingRepository.getKeywordCountForConfig(configId);
+ const keywordCount = Math.max(
+ existingKeywordCount,
+ Math.min(
+ MAX_KEYWORDS_PER_CONFIG,
+ existingKeywordCount + additionalKeywordCount,
+ ),
+ );
+ const { costUsd, costCredits } = estimateRankCheckCredits(
+ keywordCount,
+ config.devices,
+ config.serpDepth,
+ "live",
+ );
+ const scheduleInterval = isScheduledRankTrackingInterval(
+ config.scheduleInterval,
+ )
+ ? config.scheduleInterval
+ : null;
+ return {
+ costUsd,
+ costCredits,
+ keywordCount,
+ devicesCount: devicesCount(config.devices),
+ totalChecks: keywordCount * devicesCount(config.devices),
+ method: "live" as const,
+ existingKeywordCount,
+ additionalKeywordCount: keywordCount - existingKeywordCount,
+ scheduledEstimate: scheduleInterval
+ ? estimateScheduledRankCheckCredits(
+ keywordCount,
+ config.devices,
+ config.serpDepth,
+ scheduleInterval,
+ )
+ : undefined,
+ };
+}
+
+async function getValidatedConfig(configId: string, projectId: string) {
+ const config = await RankTrackingRepository.getConfigById({
+ configId,
+ projectId,
+ });
+ if (!config) {
+ throw new AppError("NOT_FOUND", "Rank tracking config not found");
+ }
+ return config;
+}
+
+function scheduledApprovalError(
+ scheduleInterval: "daily" | "weekly" | "monthly",
+ estimate: ReturnType,
+) {
+ return new AppError(
+ "VALIDATION_ERROR",
+ `Adding these keywords would make each ${scheduleInterval} scheduled check cost a nominal queued estimate of ${estimate.costCredits} credits (~$${estimate.costUsd.toFixed(4)} per check; ~${estimate.monthlyCostCredits} credits/month). Call estimate_rank_tracker_cost with additionalKeywordCount, show the recurring estimate and live-fallback caveat to the user, then retry with maxEstimatedScheduledCheckCredits set to the approved per-check estimate. Live fallback for rejected, failed, or timed-out queued tasks may use additional separately billed credits.`,
+ );
+}
+
+export const RankTrackingKeywordService = {
+ addKeywords,
+ removeKeywords,
+ estimateCost,
+};
diff --git a/src/server/features/rank-tracking/services/RankTrackingService.management.test.ts b/src/server/features/rank-tracking/services/RankTrackingService.management.test.ts
new file mode 100644
index 0000000..cd3b1ac
--- /dev/null
+++ b/src/server/features/rank-tracking/services/RankTrackingService.management.test.ts
@@ -0,0 +1,329 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { RankTrackingService } from "./RankTrackingService";
+
+const mocks = vi.hoisted(() => ({
+ getConfigById: vi.fn(),
+ getKeywordsForConfig: vi.fn(),
+ addKeywordsToConfig: vi.fn(),
+ removeKeywordsFromConfig: vi.fn(),
+ getKeywordCountForConfig: vi.fn(),
+ isHostedServerAuthMode: vi.fn(),
+ customerHasPaidPlan: vi.fn(),
+ beginRankCheckRun: vi.fn(),
+ createDataforseoClient: vi.fn(),
+ fetchKeywordMetricsForList: vi.fn(),
+}));
+
+vi.mock("cloudflare:workers", () => ({ env: { RANK_CHECK_WORKFLOW: {} } }));
+vi.mock(
+ "@/server/features/rank-tracking/repositories/RankTrackingRepository",
+ () => ({ RankTrackingRepository: mocks }),
+);
+vi.mock("@/server/lib/runtime-env", () => ({
+ isHostedServerAuthMode: mocks.isHostedServerAuthMode,
+}));
+vi.mock("@/server/billing/subscription", () => ({
+ customerHasPaidPlan: mocks.customerHasPaidPlan,
+}));
+vi.mock("@/server/features/rank-tracking/services/rankCheckRunGuards", () => ({
+ beginRankCheckRun: mocks.beginRankCheckRun,
+ reconcileActiveRankCheckRun: vi.fn(),
+}));
+vi.mock("@/server/lib/dataforseo", () => ({
+ createDataforseoClient: mocks.createDataforseoClient,
+ fetchKeywordMetricsForList: mocks.fetchKeywordMetricsForList,
+}));
+
+const config = {
+ id: "config_1",
+ projectId: "project_1",
+ domain: "example.com",
+ locationCode: 2840,
+ languageCode: "en",
+ locationName: null,
+ devices: "both" as const,
+ serpDepth: 10,
+ scheduleInterval: "weekly" as const,
+};
+
+const billingCustomer = {
+ userId: "user_1",
+ userEmail: "user@example.com",
+ organizationId: "org_1",
+ projectId: "project_1",
+};
+
+describe("RankTrackingService management invariants", () => {
+ beforeEach(() => {
+ mocks.getConfigById.mockResolvedValue(config);
+ mocks.getKeywordsForConfig.mockResolvedValue([
+ { id: "kw_1", keyword: "seo" },
+ { id: "kw_2", keyword: "audit" },
+ ]);
+ });
+
+ it("reports only keyword rows actually inserted", async () => {
+ mocks.getKeywordsForConfig.mockResolvedValue([]);
+ mocks.addKeywordsToConfig.mockImplementation(
+ async (rows: Array<{ id: string }>) => [rows[0]?.id],
+ );
+
+ const result = await RankTrackingService.addKeywords(
+ "config_1",
+ "project_1",
+ ["SEO", "seo", "technical seo"],
+ { kind: "direct_user_action" },
+ );
+
+ expect(result).toMatchObject({ added: 1 });
+ expect(result.addedIds).toHaveLength(1);
+ expect(mocks.addKeywordsToConfig).toHaveBeenCalledWith([
+ expect.objectContaining({ keyword: "seo" }),
+ expect.objectContaining({ keyword: "technical seo" }),
+ ]);
+ });
+
+ it("requires an approved estimate before increasing scheduled spend", async () => {
+ mocks.getKeywordsForConfig.mockResolvedValue([]);
+
+ const error: unknown = await RankTrackingService.addKeywords(
+ "config_1",
+ "project_1",
+ ["seo", "technical seo"],
+ { kind: "credit_ceiling" },
+ ).catch((cause: unknown) => cause);
+ expect(error).toBeInstanceOf(Error);
+ if (!(error instanceof Error) || !("code" in error)) throw error;
+ expect(error.code).toBe("VALIDATION_ERROR");
+ expect(error.message).toContain("nominal queued estimate");
+ expect(error.message).toContain("Live fallback");
+ expect(mocks.addKeywordsToConfig).not.toHaveBeenCalled();
+ });
+
+ it("adds scheduled keywords at the approved estimate", async () => {
+ mocks.getKeywordsForConfig.mockResolvedValue([]);
+ mocks.getKeywordCountForConfig.mockResolvedValue(2);
+ mocks.addKeywordsToConfig.mockImplementation(
+ async (rows: Array<{ id: string }>) => rows.map((row) => row.id),
+ );
+
+ await expect(
+ RankTrackingService.addKeywords(
+ "config_1",
+ "project_1",
+ ["seo", "technical seo"],
+ {
+ kind: "credit_ceiling",
+ maxEstimatedScheduledCheckCredits: 4,
+ },
+ ),
+ ).resolves.toMatchObject({
+ added: 2,
+ scheduledEstimate: {
+ scheduleInterval: "weekly",
+ costCredits: 4,
+ checksPerMonth: 4,
+ },
+ });
+ expect(mocks.addKeywordsToConfig).toHaveBeenCalledTimes(1);
+ });
+
+ it("rolls back its inserts when a concurrent add exceeds the estimate", async () => {
+ mocks.getKeywordsForConfig.mockResolvedValue([]);
+ mocks.getKeywordCountForConfig.mockResolvedValue(3);
+ mocks.addKeywordsToConfig.mockImplementation(
+ async (rows: Array<{ id: string }>) => rows.map((row) => row.id),
+ );
+ mocks.removeKeywordsFromConfig.mockImplementation(
+ async (ids: string[]) => ids,
+ );
+
+ const error: unknown = await RankTrackingService.addKeywords(
+ "config_1",
+ "project_1",
+ ["seo", "technical seo"],
+ {
+ kind: "credit_ceiling",
+ maxEstimatedScheduledCheckCredits: 4,
+ },
+ ).catch((cause: unknown) => cause);
+ expect(error).toBeInstanceOf(Error);
+ if (!(error instanceof Error) || !("code" in error)) throw error;
+ expect(error.code).toBe("VALIDATION_ERROR");
+ expect(mocks.removeKeywordsFromConfig).toHaveBeenCalledWith(
+ expect.arrayContaining([expect.any(String), expect.any(String)]),
+ "config_1",
+ );
+ });
+
+ it("does not require MCP approval for a manual tracker", async () => {
+ mocks.getConfigById.mockResolvedValue({
+ ...config,
+ scheduleInterval: "manual",
+ });
+ mocks.getKeywordsForConfig.mockResolvedValue([]);
+ mocks.addKeywordsToConfig.mockImplementation(
+ async (rows: Array<{ id: string }>) => rows.map((row) => row.id),
+ );
+
+ await expect(
+ RankTrackingService.addKeywords("config_1", "project_1", ["seo"], {
+ kind: "credit_ceiling",
+ }),
+ ).resolves.toMatchObject({ added: 1, scheduledEstimate: undefined });
+ expect(mocks.getKeywordCountForConfig).not.toHaveBeenCalled();
+ });
+
+ it("deduplicates removal IDs and reports only owned rows deleted", async () => {
+ mocks.removeKeywordsFromConfig.mockResolvedValue(["owned_id"]);
+
+ const result = await RankTrackingService.removeKeywords(
+ "config_1",
+ "project_1",
+ ["owned_id", "foreign_id", "missing_id", "owned_id"],
+ );
+
+ expect(mocks.removeKeywordsFromConfig).toHaveBeenCalledWith(
+ ["owned_id", "foreign_id", "missing_id"],
+ "config_1",
+ );
+ expect(result).toEqual({ removed: 1, removedIds: ["owned_id"] });
+ });
+
+ it("uses the same live cost invariant exposed to the browser", async () => {
+ mocks.getKeywordCountForConfig.mockResolvedValue(5);
+
+ await expect(
+ RankTrackingService.estimateCost("config_1", "project_1"),
+ ).resolves.toMatchObject({
+ keywordCount: 5,
+ devicesCount: 2,
+ totalChecks: 10,
+ method: "live",
+ existingKeywordCount: 5,
+ additionalKeywordCount: 0,
+ scheduledEstimate: {
+ scheduleInterval: "weekly",
+ checksPerMonth: 4,
+ },
+ });
+ });
+
+ it("rejects a hosted unpaid run before keyword or workflow work", async () => {
+ mocks.isHostedServerAuthMode.mockResolvedValue(true);
+ mocks.customerHasPaidPlan.mockResolvedValue(false);
+
+ await expect(
+ RankTrackingService.triggerCheck({
+ configId: "config_1",
+ projectId: "project_1",
+ billingCustomer,
+ }),
+ ).rejects.toMatchObject({ code: "PAYMENT_REQUIRED" });
+ expect(mocks.getKeywordsForConfig).not.toHaveBeenCalled();
+ expect(mocks.beginRankCheckRun).not.toHaveBeenCalled();
+ });
+
+ it("allows paid hosted and self-hosted runs", async () => {
+ mocks.beginRankCheckRun.mockResolvedValue({
+ ok: true,
+ runId: "run_1",
+ });
+
+ mocks.isHostedServerAuthMode.mockResolvedValue(true);
+ mocks.customerHasPaidPlan.mockResolvedValue(true);
+ await expect(
+ RankTrackingService.triggerCheck({
+ configId: "config_1",
+ projectId: "project_1",
+ billingCustomer,
+ }),
+ ).resolves.toEqual({ ok: true, runId: "run_1" });
+
+ mocks.isHostedServerAuthMode.mockResolvedValue(false);
+ // Isolate the second half of this test so it proves self-hosted mode skips
+ // the hosted billing lookup.
+ mocks.customerHasPaidPlan.mockClear();
+ await expect(
+ RankTrackingService.triggerCheck({
+ configId: "config_1",
+ projectId: "project_1",
+ billingCustomer,
+ }),
+ ).resolves.toEqual({ ok: true, runId: "run_1" });
+ expect(mocks.customerHasPaidPlan).not.toHaveBeenCalled();
+ });
+
+ it("rejects a run above its approved credit ceiling", async () => {
+ const error: unknown = await RankTrackingService.triggerCheck({
+ configId: "config_1",
+ projectId: "project_1",
+ billingCustomer,
+ maxCostCredits: 11,
+ }).catch((cause: unknown) => cause);
+ expect(error).toBeInstanceOf(Error);
+ if (!(error instanceof Error) || !("code" in error)) throw error;
+ expect(error.code).toBe("VALIDATION_ERROR");
+ expect(error.message).toContain("costs 12 credits");
+ expect(mocks.beginRankCheckRun).not.toHaveBeenCalled();
+ });
+
+ it("starts a run at or below its approved credit ceiling", async () => {
+ mocks.beginRankCheckRun.mockResolvedValue({
+ ok: true,
+ runId: "run_1",
+ });
+
+ await expect(
+ RankTrackingService.triggerCheck({
+ configId: "config_1",
+ projectId: "project_1",
+ billingCustomer,
+ maxCostCredits: 12,
+ }),
+ ).resolves.toEqual({ ok: true, runId: "run_1" });
+ expect(mocks.beginRankCheckRun).toHaveBeenCalledWith(
+ expect.objectContaining({ maxCostCredits: 12 }),
+ );
+ });
+
+ it("rejects hosted unpaid metrics refresh before provider work", async () => {
+ mocks.isHostedServerAuthMode.mockResolvedValue(true);
+ mocks.customerHasPaidPlan.mockResolvedValue(false);
+
+ await expect(
+ RankTrackingService.refreshKeywordMetrics(
+ "config_1",
+ "project_1",
+ billingCustomer,
+ ),
+ ).rejects.toMatchObject({ code: "PAYMENT_REQUIRED" });
+ expect(mocks.createDataforseoClient).not.toHaveBeenCalled();
+ expect(mocks.fetchKeywordMetricsForList).not.toHaveBeenCalled();
+ });
+
+ it("allows self-hosted metrics refresh without a plan check", async () => {
+ mocks.isHostedServerAuthMode.mockResolvedValue(false);
+ mocks.createDataforseoClient.mockReturnValue({});
+ mocks.fetchKeywordMetricsForList.mockResolvedValue([]);
+
+ await expect(
+ RankTrackingService.refreshKeywordMetrics(
+ "config_1",
+ "project_1",
+ billingCustomer,
+ ),
+ ).resolves.toEqual({ updated: 0 });
+ expect(mocks.customerHasPaidPlan).not.toHaveBeenCalled();
+ expect(mocks.fetchKeywordMetricsForList).toHaveBeenCalledTimes(1);
+ });
+
+ it("rejects missing or foreign trackers with NOT_FOUND before mutation", async () => {
+ mocks.getConfigById.mockResolvedValue(null);
+
+ await expect(
+ RankTrackingService.removeKeywords("foreign", "project_1", ["kw_1"]),
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
+ expect(mocks.removeKeywordsFromConfig).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/server/features/rank-tracking/services/RankTrackingService.test.ts b/src/server/features/rank-tracking/services/RankTrackingService.test.ts
index 149d51c..d26a8c6 100644
--- a/src/server/features/rank-tracking/services/RankTrackingService.test.ts
+++ b/src/server/features/rank-tracking/services/RankTrackingService.test.ts
@@ -1,7 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
+import { MAX_CONFIGS_PER_PROJECT } from "@/shared/rank-tracking";
+import { RankTrackingService } from "./RankTrackingService";
const mocks = vi.hoisted(() => ({
getConfigByProjectDomainLocation: vi.fn(),
+ getConfigById: vi.fn(),
getConfigsForProject: vi.fn(),
createConfig: vi.fn(),
updateConfig: vi.fn(),
@@ -39,19 +42,29 @@ const baseInput = {
};
describe("RankTrackingService.createConfig", () => {
- beforeEach(() => {
- vi.resetModules();
- for (const mock of Object.values(mocks)) mock.mockReset();
- });
+ beforeEach(() => {});
it("reactivates an archived config instead of throwing, applying the new settings", async () => {
mocks.getConfigByProjectDomainLocation.mockResolvedValue(archivedConfig);
mocks.getConfigsForProject.mockResolvedValue([]);
mocks.updateConfig.mockResolvedValue(undefined);
- const { RankTrackingService } = await import("./RankTrackingService");
+ mocks.getConfigById.mockResolvedValue({
+ ...archivedConfig,
+ languageCode: "es",
+ devices: "desktop",
+ serpDepth: 40,
+ scheduleInterval: "daily",
+ isActive: true,
+ lastSkipReason: null,
+ });
- await expect(RankTrackingService.createConfig(baseInput)).resolves.toEqual({
- configId: "config_archived",
+ await expect(
+ RankTrackingService.createConfig(baseInput),
+ ).resolves.toMatchObject({
+ id: "config_archived",
+ isActive: true,
+ languageCode: "es",
+ devices: "desktop",
});
expect(mocks.updateConfig).toHaveBeenCalledTimes(1);
@@ -76,7 +89,6 @@ describe("RankTrackingService.createConfig", () => {
...archivedConfig,
isActive: true,
});
- const { RankTrackingService } = await import("./RankTrackingService");
await expect(
RankTrackingService.createConfig(baseInput),
@@ -89,7 +101,6 @@ describe("RankTrackingService.createConfig", () => {
mocks.getConfigByProjectDomainLocation.mockResolvedValue(null);
mocks.getConfigsForProject.mockResolvedValue([]);
mocks.createConfig.mockResolvedValue(undefined);
- const { RankTrackingService } = await import("./RankTrackingService");
// Local config: the lookup must be scoped to this exact city, so an
// existing national row for the same domain doesn't collide.
@@ -115,7 +126,6 @@ describe("RankTrackingService.createConfig", () => {
});
it("rejects reactivating an archived config when the project is at the active-config cap", async () => {
- const { MAX_CONFIGS_PER_PROJECT } = await import("@/shared/rank-tracking");
mocks.getConfigByProjectDomainLocation.mockResolvedValue(archivedConfig);
mocks.getConfigsForProject.mockResolvedValue(
Array.from({ length: MAX_CONFIGS_PER_PROJECT }, (_, i) => ({
@@ -124,7 +134,6 @@ describe("RankTrackingService.createConfig", () => {
isActive: true,
})),
);
- const { RankTrackingService } = await import("./RankTrackingService");
await expect(
RankTrackingService.createConfig(baseInput),
@@ -137,16 +146,15 @@ describe("RankTrackingService.createConfig", () => {
mocks.getConfigByProjectDomainLocation.mockResolvedValue(null);
mocks.getConfigsForProject.mockResolvedValue([]);
mocks.createConfig.mockResolvedValue(undefined);
- const { RankTrackingService } = await import("./RankTrackingService");
const result = await RankTrackingService.createConfig(baseInput);
- expect(result.configId).toBeTruthy();
+ expect(result.id).toBeTruthy();
expect(mocks.createConfig).toHaveBeenCalledTimes(1);
expect(mocks.updateConfig).not.toHaveBeenCalled();
expect(mocks.createConfig).toHaveBeenCalledWith(
expect.objectContaining({
- id: result.configId,
+ id: result.id,
projectId: "project_1",
domain: "acme.com",
devices: "desktop",
@@ -160,7 +168,6 @@ describe("RankTrackingService.createConfig", () => {
mocks.getConfigByProjectDomainLocation.mockResolvedValue(null);
mocks.getConfigsForProject.mockResolvedValue([]);
mocks.createConfig.mockResolvedValue(undefined);
- const { RankTrackingService } = await import("./RankTrackingService");
await RankTrackingService.createConfig({
projectId: "project_1",
@@ -178,7 +185,6 @@ describe("RankTrackingService.createConfig", () => {
mocks.getConfigByProjectDomainLocation.mockResolvedValue(null);
mocks.getConfigsForProject.mockResolvedValue([]);
mocks.createConfig.mockResolvedValue(undefined);
- const { RankTrackingService } = await import("./RankTrackingService");
await RankTrackingService.createConfig({
projectId: "project_1",
diff --git a/src/server/features/rank-tracking/services/RankTrackingService.ts b/src/server/features/rank-tracking/services/RankTrackingService.ts
index ed699b3..293bd3a 100644
--- a/src/server/features/rank-tracking/services/RankTrackingService.ts
+++ b/src/server/features/rank-tracking/services/RankTrackingService.ts
@@ -1,11 +1,15 @@
import { env } from "cloudflare:workers";
-import type { BillingCustomerContext } from "@/server/billing/subscription";
+import {
+ customerHasPaidPlan,
+ type BillingCustomerContext,
+} from "@/server/billing/subscription";
import {
createDataforseoClient,
fetchKeywordMetricsForList,
} from "@/server/lib/dataforseo";
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
import { AppError } from "@/server/lib/errors";
+import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
import type {
RankTrackingConfig,
RankCheckTriggerResult,
@@ -17,12 +21,14 @@ import {
import {
estimateRankCheckCredits,
computeNextCheckAt,
- devicesCount,
isScheduledRankTrackingInterval,
- MAX_KEYWORDS_PER_CONFIG,
MAX_CONFIGS_PER_PROJECT,
+ rankCheckCostApprovalError,
} from "@/shared/rank-tracking";
import { resolveMarket } from "@/shared/keyword-locations";
+import { getLatestResults } from "./rankTrackingResults";
+import { toSqliteTimestamp } from "@/server/features/rank-tracking/rankTrackingTimestamps";
+import { RankTrackingKeywordService } from "./RankTrackingKeywordService";
// ---------------------------------------------------------------------------
// Config
@@ -97,12 +103,11 @@ async function createConfig(input: {
lastSkipReason: null,
});
- return { configId: existing.id };
+ return getValidatedConfig(existing.id, input.projectId);
}
const configId = crypto.randomUUID();
-
- await RankTrackingRepository.createConfig({
+ const config: RankTrackingConfig = {
id: configId,
projectId: input.projectId,
domain: normalizedDomain,
@@ -113,9 +118,15 @@ async function createConfig(input: {
serpDepth: input.serpDepth,
scheduleInterval,
nextCheckAt,
- });
+ isActive: true,
+ lastCheckedAt: null,
+ lastSkipReason: null,
+ createdAt: toSqliteTimestamp(new Date()),
+ };
- return { configId };
+ await RankTrackingRepository.createConfig(config);
+
+ return config;
}
async function updateConfig(
@@ -158,64 +169,6 @@ async function updateConfig(
await RankTrackingRepository.updateConfig(configId, projectId, updates);
}
-// ---------------------------------------------------------------------------
-// Keywords
-// ---------------------------------------------------------------------------
-
-async function addKeywords(
- configId: string,
- projectId: string,
- keywords: string[],
-) {
- await getValidatedConfig(configId, projectId);
-
- // Filter out keywords that already exist for this config.
- // We must do this before inserting because onConflictDoNothing silently
- // skips duplicates but we pre-generate UUIDs — returning those phantom IDs
- // would cause the auto-check workflow to find no keywords and fail.
- const existing = await RankTrackingRepository.getKeywordsForConfig(configId);
-
- if (existing.length >= MAX_KEYWORDS_PER_CONFIG) {
- throw new AppError(
- "INTERNAL_ERROR",
- `Maximum ${MAX_KEYWORDS_PER_CONFIG} keywords per domain. Currently tracking ${existing.length}.`,
- );
- }
-
- const existingKeywords = new Set(existing.map((kw) => kw.keyword));
- const available = MAX_KEYWORDS_PER_CONFIG - existing.length;
-
- const seen = new Set();
- const rows: Array<{ id: string; configId: string; keyword: string }> = [];
- for (const raw of keywords) {
- if (rows.length >= available) break;
- const normalized = raw.trim().toLowerCase();
- if (
- normalized &&
- !seen.has(normalized) &&
- !existingKeywords.has(normalized)
- ) {
- seen.add(normalized);
- rows.push({ id: crypto.randomUUID(), configId, keyword: normalized });
- }
- }
-
- if (rows.length > 0) {
- await RankTrackingRepository.addKeywordsToConfig(rows);
- }
-
- return { added: rows.length, addedIds: rows.map((r) => r.id) };
-}
-
-async function removeKeywords(
- configId: string,
- projectId: string,
- keywordIds: string[],
-) {
- await getValidatedConfig(configId, projectId);
- await RankTrackingRepository.removeKeywordsFromConfig(keywordIds, configId);
-}
-
// ---------------------------------------------------------------------------
// Trigger a manual check
// ---------------------------------------------------------------------------
@@ -225,9 +178,12 @@ async function triggerCheck(input: {
projectId: string;
billingCustomer: BillingCustomerContext;
keywordIds?: string[];
+ maxCostCredits?: number;
}): Promise {
const config = await getValidatedConfig(input.configId, input.projectId);
+ await requireRankCheckAccess(input.billingCustomer.organizationId);
+
const keywords = await RankTrackingRepository.getKeywordsForConfig(config.id);
if (keywords.length === 0) {
throw new AppError(
@@ -236,6 +192,21 @@ async function triggerCheck(input: {
);
}
+ if (input.maxCostCredits != null) {
+ const { costCredits } = estimateRankCheckCredits(
+ keywords.length,
+ config.devices,
+ config.serpDepth,
+ "live",
+ );
+ if (costCredits > input.maxCostCredits) {
+ throw new AppError(
+ "VALIDATION_ERROR",
+ rankCheckCostApprovalError(costCredits, input.maxCostCredits),
+ );
+ }
+ }
+
return beginRankCheckRun({
workflow: env.RANK_CHECK_WORKFLOW,
config,
@@ -248,6 +219,7 @@ async function triggerCheck(input: {
},
keywordsTotal: input.keywordIds ? input.keywordIds.length : keywords.length,
keywordIds: input.keywordIds,
+ maxCostCredits: input.maxCostCredits,
trigger: "manual",
workflowStartErrorMessage: "Failed to start rank check workflow",
});
@@ -283,10 +255,9 @@ async function refreshKeywordMetrics(
projectId: string,
billingCustomer: BillingCustomerContext,
): Promise<{ updated: number }> {
- const [config, keywords] = await Promise.all([
- getValidatedConfig(configId, projectId),
- RankTrackingRepository.getKeywordsForConfig(configId),
- ]);
+ const config = await getValidatedConfig(configId, projectId);
+ await requireRankCheckAccess(billingCustomer.organizationId);
+ const keywords = await RankTrackingRepository.getKeywordsForConfig(configId);
if (keywords.length === 0) return { updated: 0 };
const client = createDataforseoClient(billingCustomer);
@@ -325,26 +296,26 @@ async function refreshKeywordMetrics(
}
// ---------------------------------------------------------------------------
-// Cost estimation
+// MCP/browser read models and access policy
// ---------------------------------------------------------------------------
-async function estimateCost(configId: string, projectId: string) {
+async function getConfigs(projectId: string) {
+ return RankTrackingRepository.getConfigsForProject(projectId);
+}
+
+async function getTracker(configId: string, projectId: string) {
const config = await getValidatedConfig(configId, projectId);
- const keywordCount =
- await RankTrackingRepository.getKeywordCountForConfig(configId);
- // Estimates the cost of a manual "check now", which always runs live.
- const { costUsd, costCredits } = estimateRankCheckCredits(
- keywordCount,
- config.devices,
- config.serpDepth,
- "live",
+ const results = await getLatestResults(configId, projectId);
+ return { config, results };
+}
+
+async function requireRankCheckAccess(organizationId: string) {
+ if (!(await isHostedServerAuthMode())) return;
+ if (await customerHasPaidPlan(organizationId)) return;
+ throw new AppError(
+ "PAYMENT_REQUIRED",
+ "Upgrade to the paid plan to run rank checks",
);
- return {
- costUsd,
- costCredits,
- keywordCount,
- devicesCount: devicesCount(config.devices),
- };
}
// ---------------------------------------------------------------------------
@@ -357,7 +328,7 @@ async function getValidatedConfig(configId: string, projectId: string) {
projectId,
});
if (!config) {
- throw new AppError("INTERNAL_ERROR", "Rank tracking config not found");
+ throw new AppError("NOT_FOUND", "Rank tracking config not found");
}
return config;
}
@@ -403,10 +374,13 @@ function formatRun(
export const RankTrackingService = {
createConfig,
updateConfig,
- addKeywords,
- removeKeywords,
+ addKeywords: RankTrackingKeywordService.addKeywords,
+ removeKeywords: RankTrackingKeywordService.removeKeywords,
triggerCheck,
getLatestRun,
- estimateCost,
+ estimateCost: RankTrackingKeywordService.estimateCost,
refreshKeywordMetrics,
+ getConfigs,
+ getTracker,
+ requireRankCheckAccess,
};
diff --git a/src/server/features/rank-tracking/services/rankCheckRunGuards.test.ts b/src/server/features/rank-tracking/services/rankCheckRunGuards.test.ts
new file mode 100644
index 0000000..0f4ebac
--- /dev/null
+++ b/src/server/features/rank-tracking/services/rankCheckRunGuards.test.ts
@@ -0,0 +1,113 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { beginRankCheckRun } from "./rankCheckRunGuards";
+
+const mocks = vi.hoisted(() => ({
+ tryCreateRun: vi.fn(),
+ getActiveRunForConfig: vi.fn(),
+ getRunById: vi.fn(),
+ updateRun: vi.fn(),
+ getWorkflow: vi.fn(),
+}));
+
+vi.mock("cloudflare:workers", () => ({
+ env: {
+ RANK_CHECK_WORKFLOW: { get: mocks.getWorkflow },
+ },
+}));
+vi.mock(
+ "@/server/features/rank-tracking/repositories/RankTrackingRepository",
+ () => ({ RankTrackingRepository: mocks }),
+);
+
+const run = {
+ id: "run_1",
+ configId: "config_1",
+ projectId: "project_1",
+ status: "pending" as const,
+ keywordsTotal: 2,
+ keywordsChecked: 0,
+ isSubsetRun: false,
+ errorMessage: null,
+ startedAt: new Date().toISOString(),
+ completedAt: null,
+};
+
+const input = {
+ config: {
+ id: "config_1",
+ domain: "example.com",
+ locationCode: 2840,
+ languageCode: "en",
+ locationName: null,
+ devices: "desktop" as const,
+ serpDepth: 20,
+ },
+ projectId: "project_1",
+ billingCustomer: {
+ userId: "user_1",
+ userEmail: "user@example.com",
+ organizationId: "org_1",
+ projectId: "project_1",
+ },
+ keywordsTotal: 2,
+ trigger: "manual" as const,
+ workflowStartErrorMessage: "failed",
+};
+
+describe("beginRankCheckRun", () => {
+ beforeEach(() => {});
+
+ it("returns the locally generated run ID without a fallible post-start read", async () => {
+ mocks.tryCreateRun.mockResolvedValue(true);
+ const create = vi
+ .fn<(input: { params: { maxCostCredits?: number } }) => Promise>()
+ .mockResolvedValue(undefined);
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- only create is exercised by this unit test
+ const workflow = { create } as unknown as Env["RANK_CHECK_WORKFLOW"];
+
+ const result = await beginRankCheckRun({ ...input, workflow });
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error("expected a created run");
+ expect(result.runId).toEqual(expect.any(String));
+ expect(create).toHaveBeenCalledTimes(1);
+ expect(create.mock.calls[0]?.[0].params.maxCostCredits).toBeUndefined();
+ expect(mocks.getRunById).not.toHaveBeenCalled();
+ });
+
+ it("passes the approved credit ceiling into the workflow payload", async () => {
+ mocks.tryCreateRun.mockResolvedValue(true);
+ const create = vi
+ .fn<(input: { params: { maxCostCredits?: number } }) => Promise>()
+ .mockResolvedValue(undefined);
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- only create is exercised by this unit test
+ const workflow = { create } as unknown as Env["RANK_CHECK_WORKFLOW"];
+
+ await beginRankCheckRun({
+ ...input,
+ workflow,
+ maxCostCredits: 12,
+ });
+
+ expect(create.mock.calls[0]?.[0].params.maxCostCredits).toBe(12);
+ });
+
+ it("does not create another workflow when a run is already active", async () => {
+ const blocker = { ...run, id: "run_0", status: "running" as const };
+ mocks.tryCreateRun.mockResolvedValue(false);
+ mocks.getActiveRunForConfig.mockResolvedValue(blocker);
+ mocks.getWorkflow.mockResolvedValue({
+ status: vi.fn().mockResolvedValue({ status: "running" }),
+ });
+ const create = vi.fn();
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- no workflow methods should run on the blocked path
+ const workflow = { create } as unknown as Env["RANK_CHECK_WORKFLOW"];
+
+ await expect(beginRankCheckRun({ ...input, workflow })).resolves.toEqual({
+ ok: false,
+ reason: "already_running",
+ blockingRunId: "run_0",
+ });
+ expect(create).not.toHaveBeenCalled();
+ expect(mocks.tryCreateRun).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/server/features/rank-tracking/services/rankCheckRunGuards.ts b/src/server/features/rank-tracking/services/rankCheckRunGuards.ts
index 85dcaa5..19bd3ec 100644
--- a/src/server/features/rank-tracking/services/rankCheckRunGuards.ts
+++ b/src/server/features/rank-tracking/services/rankCheckRunGuards.ts
@@ -144,13 +144,14 @@ export async function beginRankCheckRun(input: {
billingCustomer: BillingCustomerContext;
keywordsTotal: number;
keywordIds?: string[];
+ maxCostCredits?: number;
trigger: "manual" | "scheduled";
workflowStartErrorMessage: string;
}): Promise {
// At most two attempts: once normally, once after clearing a stale blocker.
for (let attempt = 0; attempt < 2; attempt++) {
const runId = crypto.randomUUID();
- const inserted = await RankTrackingRepository.tryCreateRun({
+ const created = await RankTrackingRepository.tryCreateRun({
id: runId,
configId: input.config.id,
projectId: input.projectId,
@@ -158,7 +159,7 @@ export async function beginRankCheckRun(input: {
isSubsetRun: (input.keywordIds?.length ?? 0) > 0,
});
- if (inserted) {
+ if (created) {
try {
await input.workflow.create({
id: runId,
@@ -175,6 +176,7 @@ export async function beginRankCheckRun(input: {
serpDepth: input.config.serpDepth,
trigger: input.trigger,
keywordIds: input.keywordIds,
+ maxCostCredits: input.maxCostCredits,
},
});
} catch (error) {
diff --git a/src/server/features/rank-tracking/services/rankTrackingResults.test.ts b/src/server/features/rank-tracking/services/rankTrackingResults.test.ts
new file mode 100644
index 0000000..321f4e8
--- /dev/null
+++ b/src/server/features/rank-tracking/services/rankTrackingResults.test.ts
@@ -0,0 +1,84 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { getLatestResults } from "./rankTrackingResults";
+
+const mocks = vi.hoisted(() => ({
+ getConfigById: vi.fn(),
+ getKeywordsForConfig: vi.fn(),
+ getLatestSnapshotsForKeywords: vi.fn(),
+ getSnapshotsBeforeDate: vi.fn(),
+ getLatestRunForConfig: vi.fn(),
+ getEarliestSnapshotsForKeywords: vi.fn(),
+}));
+
+vi.mock(
+ "@/server/features/rank-tracking/repositories/RankTrackingRepository",
+ () => ({ RankTrackingRepository: mocks }),
+);
+
+describe("getLatestResults", () => {
+ beforeEach(() => {
+ mocks.getConfigById.mockResolvedValue({ id: "config_1" });
+ mocks.getKeywordsForConfig.mockResolvedValue([]);
+ mocks.getLatestSnapshotsForKeywords.mockResolvedValue([]);
+ mocks.getSnapshotsBeforeDate.mockResolvedValue([]);
+ mocks.getEarliestSnapshotsForKeywords.mockResolvedValue([]);
+ });
+
+ it("keeps snapshot freshness when a newer run fails before writing snapshots", async () => {
+ mocks.getKeywordsForConfig.mockResolvedValue([
+ {
+ id: "kw_1",
+ keyword: "open seo",
+ searchVolume: 100,
+ keywordDifficulty: 10,
+ cpc: 1,
+ },
+ ]);
+ mocks.getLatestSnapshotsForKeywords.mockResolvedValue([
+ {
+ trackingKeywordId: "kw_1",
+ device: "desktop",
+ runId: "run_1",
+ checkedAt: "2026-08-01 10:00:00",
+ position: 3,
+ url: "https://example.com/",
+ serpFeatures: null,
+ },
+ ]);
+ mocks.getLatestRunForConfig.mockResolvedValue({
+ id: "run_2",
+ status: "failed",
+ errorMessage: "Provider request timed out",
+ });
+
+ await expect(
+ getLatestResults("config_1", "project_1"),
+ ).resolves.toMatchObject({
+ run: {
+ id: "run_2",
+ lastCheckedAt: "2026-08-01 10:00:00",
+ status: "failed",
+ errorMessage: "Provider request timed out",
+ },
+ });
+ });
+
+ it("surfaces the latest failed run and its error message", async () => {
+ mocks.getLatestRunForConfig.mockResolvedValue({
+ id: "run_1",
+ status: "failed",
+ errorMessage: "Provider request timed out",
+ });
+
+ await expect(
+ getLatestResults("config_1", "project_1"),
+ ).resolves.toMatchObject({
+ run: {
+ id: "run_1",
+ lastCheckedAt: null,
+ status: "failed",
+ errorMessage: "Provider request timed out",
+ },
+ });
+ });
+});
diff --git a/src/server/features/rank-tracking/services/rankTrackingResults.ts b/src/server/features/rank-tracking/services/rankTrackingResults.ts
index 5ae51e0..99a10cb 100644
--- a/src/server/features/rank-tracking/services/rankTrackingResults.ts
+++ b/src/server/features/rank-tracking/services/rankTrackingResults.ts
@@ -24,7 +24,12 @@ export async function getLatestResults(
comparePeriod: ComparePeriod = "7d",
): Promise<{
rows: RankTrackingRow[];
- run: { id: string; lastCheckedAt: string } | null;
+ run: {
+ id: string;
+ lastCheckedAt: string | null;
+ status: "pending" | "running" | "completed" | "failed";
+ errorMessage: string | null;
+ } | null;
}> {
const days = PERIOD_DAYS[comparePeriod];
const targetDate = toSqliteTimestamp(
@@ -36,15 +41,21 @@ export async function getLatestResults(
// be a continent away. The project-scoped config lookup doubles as the
// authorization gate for the configId-keyed reads racing alongside it: when
// config is null, throw without returning anything from the other reads.
- const [config, activeKeywords, currentSnapshots, comparisonSnapshots] =
- await Promise.all([
- RankTrackingRepository.getConfigById({ configId, projectId }),
- RankTrackingRepository.getKeywordsForConfig(configId),
- // Latest snapshot per keyword per device (across all completed runs)
- RankTrackingRepository.getLatestSnapshotsForKeywords(configId),
- // Comparison snapshots from before the target date
- RankTrackingRepository.getSnapshotsBeforeDate(configId, targetDate),
- ]);
+ const [
+ config,
+ activeKeywords,
+ currentSnapshots,
+ comparisonSnapshots,
+ latestRun,
+ ] = await Promise.all([
+ RankTrackingRepository.getConfigById({ configId, projectId }),
+ RankTrackingRepository.getKeywordsForConfig(configId),
+ // Latest snapshot per keyword per device (across all completed runs)
+ RankTrackingRepository.getLatestSnapshotsForKeywords(configId),
+ // Comparison snapshots from before the target date
+ RankTrackingRepository.getSnapshotsBeforeDate(configId, targetDate),
+ RankTrackingRepository.getLatestRunForConfig(configId),
+ ]);
if (!config) {
throw new AppError("INTERNAL_ERROR", "Rank tracking config not found");
}
@@ -102,8 +113,8 @@ export async function getLatestResults(
]),
);
- // Determine the most recent snapshot time for the run info
- let latestRunId: string | null = null;
+ // Freshness comes from the newest snapshot regardless of which run wrote
+ // it, so a newer failed run doesn't erase the date of the results shown.
let latestStartedAt: string | null = null;
for (const snapshot of currentSnapshots) {
@@ -116,19 +127,21 @@ export async function getLatestResults(
) ?? null,
);
- // Track the most recent run for the header display
if (!latestStartedAt || snapshot.checkedAt > latestStartedAt) {
- latestRunId = snapshot.runId;
latestStartedAt = snapshot.checkedAt;
}
}
return {
rows: [...rows.values()],
- run:
- latestRunId && latestStartedAt
- ? { id: latestRunId, lastCheckedAt: latestStartedAt }
- : null,
+ run: latestRun
+ ? {
+ id: latestRun.id,
+ lastCheckedAt: latestStartedAt,
+ status: latestRun.status,
+ errorMessage: latestRun.errorMessage,
+ }
+ : null,
};
}
diff --git a/src/server/lib/dataforseo/shared.ts b/src/server/lib/dataforseo/shared.ts
index b692eea..215191d 100644
--- a/src/server/lib/dataforseo/shared.ts
+++ b/src/server/lib/dataforseo/shared.ts
@@ -10,8 +10,7 @@ export const CHATGPT_LANGUAGE_CODE = "en";
export type LlmPlatform = "chat_gpt" | "google";
-/** Max tasks DataForSEO accepts in a single task_post request. */
-export const MAX_TASKS_PER_POST = 100;
+export { MAX_TASKS_PER_POST } from "@/shared/rank-tracking";
// DataForSEO's LLM-mentions `target` array accepts domain OR keyword entries.
// We always pass exactly one target per call.
diff --git a/src/server/lib/ga4Client.test.ts b/src/server/lib/ga4Client.test.ts
new file mode 100644
index 0000000..cd1312b
--- /dev/null
+++ b/src/server/lib/ga4Client.test.ts
@@ -0,0 +1,412 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { createGa4AdminClient, createGa4DataClient } from "./ga4Client";
+import {
+ Ga4AdminApiError,
+ Ga4DataApiError,
+ Ga4MalformedResponseError,
+ Ga4TokenError,
+} from "./ga4Errors";
+
+const mocks = vi.hoisted(() => ({
+ getAccessToken: vi.fn(),
+ fetch: vi.fn(),
+}));
+
+vi.mock("@/lib/auth", () => ({
+ getAuth: () => ({ api: { getAccessToken: mocks.getAccessToken } }),
+}));
+
+function jsonResponse(body: unknown, status = 200) {
+ return Response.json(body, { status });
+}
+
+function requestUrl(input: RequestInfo | URL): string {
+ if (typeof input === "string") return input;
+ return input instanceof URL ? input.href : input.url;
+}
+
+describe("ga4Client admin API", () => {
+ beforeEach(() => {
+ mocks.getAccessToken.mockResolvedValue({ accessToken: "ga4_tok" });
+ vi.stubGlobal("fetch", mocks.fetch);
+ });
+
+ afterEach(() => vi.unstubAllGlobals());
+
+ it("uses the dedicated Analytics grant and paginates property discovery", async () => {
+ mocks.fetch
+ .mockResolvedValueOnce(
+ jsonResponse({
+ accountSummaries: [
+ {
+ account: "accounts/1",
+ displayName: "Agency",
+ propertySummaries: [
+ { property: "properties/11", displayName: "Site A" },
+ ],
+ },
+ ],
+ nextPageToken: "page-2",
+ }),
+ )
+ .mockResolvedValueOnce(
+ jsonResponse({
+ accountSummaries: [
+ {
+ account: "accounts/2",
+ displayName: "Client",
+ propertySummaries: [
+ { property: "properties/22", displayName: "Site B" },
+ ],
+ },
+ ],
+ }),
+ );
+
+ await expect(
+ createGa4AdminClient({
+ userId: "u1",
+ ga4AccountId: "google-sub-a",
+ }).listProperties(),
+ ).resolves.toEqual([
+ {
+ propertyId: "properties/11",
+ displayName: "Site A",
+ accountDisplayName: "Agency",
+ },
+ {
+ propertyId: "properties/22",
+ displayName: "Site B",
+ accountDisplayName: "Client",
+ },
+ ]);
+ expect(mocks.getAccessToken).toHaveBeenCalledWith({
+ body: {
+ providerId: "google-analytics",
+ userId: "u1",
+ accountId: "google-sub-a",
+ },
+ });
+ const secondUrl = mocks.fetch.mock.calls[1]?.[0];
+ const secondUrlText =
+ typeof secondUrl === "string"
+ ? secondUrl
+ : secondUrl instanceof URL
+ ? secondUrl.toString()
+ : secondUrl?.url;
+ expect(secondUrlText).toContain("pageToken=page-2");
+ expect(mocks.getAccessToken).toHaveBeenCalledTimes(1);
+ });
+
+ it("loads and validates the selected property's reporting metadata", async () => {
+ mocks.fetch.mockResolvedValue(
+ jsonResponse({
+ name: "properties/11",
+ displayName: "Site A",
+ timeZone: "America/New_York",
+ currencyCode: "USD",
+ }),
+ );
+ const property = await createGa4AdminClient({
+ userId: "u1",
+ ga4AccountId: "google-sub-a",
+ }).getProperty("properties/11");
+
+ expect(property.timeZone).toBe("America/New_York");
+ expect(mocks.fetch.mock.calls[0]?.[0]).toBe(
+ "https://analyticsadmin.googleapis.com/v1beta/properties/11",
+ );
+ });
+
+ it("classifies a rejected grant as a typed 401", async () => {
+ mocks.fetch.mockResolvedValue(jsonResponse({ error: "expired" }, 401));
+ await expect(
+ createGa4AdminClient({
+ userId: "u1",
+ ga4AccountId: "google-sub-a",
+ }).listProperties(),
+ ).rejects.toBeInstanceOf(Ga4AdminApiError);
+ await expect(
+ createGa4AdminClient({
+ userId: "u1",
+ ga4AccountId: "google-sub-a",
+ }).listProperties(),
+ ).rejects.toMatchObject({ status: 401 });
+ });
+
+ it("throws a token error when Better Auth cannot mint an access token", async () => {
+ mocks.getAccessToken.mockRejectedValue(new Error("revoked"));
+ await expect(
+ createGa4AdminClient({
+ userId: "u1",
+ ga4AccountId: "google-sub-a",
+ }).listProperties(),
+ ).rejects.toBeInstanceOf(Ga4TokenError);
+ });
+
+ it("reads streams, enhanced measurement, key events, and custom definitions", async () => {
+ mocks.fetch
+ .mockResolvedValueOnce(
+ jsonResponse({
+ dataStreams: [
+ {
+ name: "properties/11/dataStreams/22",
+ type: "WEB_DATA_STREAM",
+ displayName: "Website",
+ webStreamData: {
+ measurementId: "G-ABC123",
+ defaultUri: "https://example.com",
+ },
+ },
+ ],
+ }),
+ )
+ .mockResolvedValueOnce(
+ jsonResponse({
+ streamEnabled: true,
+ scrollsEnabled: true,
+ outboundClicksEnabled: true,
+ siteSearchEnabled: true,
+ videoEngagementEnabled: true,
+ fileDownloadsEnabled: true,
+ pageChangesEnabled: true,
+ formInteractionsEnabled: false,
+ searchQueryParameter: "q,s",
+ }),
+ )
+ .mockResolvedValueOnce(
+ jsonResponse({
+ keyEvents: [
+ {
+ eventName: "purchase",
+ countingMethod: "ONCE_PER_EVENT",
+ custom: false,
+ },
+ ],
+ }),
+ )
+ .mockResolvedValueOnce(
+ jsonResponse({
+ customDimensions: [
+ {
+ parameterName: "content_type",
+ displayName: "Content type",
+ scope: "EVENT",
+ },
+ ],
+ }),
+ )
+ .mockResolvedValueOnce(
+ jsonResponse({
+ customMetrics: [
+ {
+ parameterName: "quality_score",
+ displayName: "Quality score",
+ measurementUnit: "STANDARD",
+ scope: "EVENT",
+ },
+ ],
+ }),
+ );
+ const client = createGa4AdminClient({
+ userId: "u1",
+ ga4AccountId: "google-sub-a",
+ });
+
+ const streams = await client.listDataStreams("properties/11");
+ const enhanced = await client.getEnhancedMeasurementSettings(
+ "properties/11/dataStreams/22",
+ );
+ const keyEvents = await client.listKeyEvents("properties/11");
+ const dimensions = await client.listCustomDimensions("properties/11");
+ const metrics = await client.listCustomMetrics("properties/11");
+
+ expect(streams[0]?.webStreamData?.measurementId).toBe("G-ABC123");
+ expect(enhanced.siteSearchEnabled).toBe(true);
+ expect(keyEvents[0]?.eventName).toBe("purchase");
+ expect(dimensions[0]?.parameterName).toBe("content_type");
+ expect(metrics[0]?.parameterName).toBe("quality_score");
+ expect(mocks.fetch.mock.calls.map((call) => requestUrl(call[0]))).toEqual([
+ "https://analyticsadmin.googleapis.com/v1alpha/properties/11/dataStreams?pageSize=200",
+ "https://analyticsadmin.googleapis.com/v1alpha/properties/11/dataStreams/22/enhancedMeasurementSettings",
+ "https://analyticsadmin.googleapis.com/v1beta/properties/11/keyEvents?pageSize=200",
+ "https://analyticsadmin.googleapis.com/v1beta/properties/11/customDimensions?pageSize=200",
+ "https://analyticsadmin.googleapis.com/v1beta/properties/11/customMetrics?pageSize=200",
+ ]);
+ expect(mocks.getAccessToken).toHaveBeenCalledTimes(1);
+ });
+
+ it("converts transport failures to a typed upstream error", async () => {
+ mocks.fetch.mockRejectedValue(new TypeError("connection reset"));
+
+ await expect(
+ createGa4AdminClient({
+ userId: "u1",
+ ga4AccountId: "google-sub-a",
+ }).listProperties(),
+ ).rejects.toMatchObject({ status: 0 });
+ });
+});
+
+const reportRequest = {
+ dateRanges: [{ startDate: "2026-07-01", endDate: "2026-07-28" }],
+ dimensions: [{ name: "hostName" }],
+ metrics: [{ name: "sessions" }],
+ offset: "0",
+ limit: "100",
+ orderBys: [{ metric: { metricName: "sessions" }, desc: true }],
+ keepEmptyRows: false as const,
+ returnPropertyQuota: true as const,
+};
+
+describe("ga4Client data API", () => {
+ beforeEach(() => {
+ mocks.getAccessToken.mockResolvedValue({ accessToken: "token" });
+ vi.stubGlobal("fetch", mocks.fetch);
+ });
+
+ afterEach(() => vi.unstubAllGlobals());
+
+ it("posts a fixed report to the selected property with its dedicated grant", async () => {
+ mocks.fetch.mockResolvedValue(
+ Response.json({
+ dimensionHeaders: [{ name: "hostName" }],
+ metricHeaders: [{ name: "sessions", type: "TYPE_INTEGER" }],
+ rows: [
+ {
+ dimensionValues: [{ value: "example.com" }],
+ metricValues: [{ value: "12" }],
+ },
+ ],
+ rowCount: 1,
+ }),
+ );
+ const result = await createGa4DataClient({
+ userId: "user_1",
+ ga4AccountId: "account_1",
+ propertyId: "properties/123",
+ }).runReport(reportRequest);
+
+ expect(result.rowCount).toBe(1);
+ expect(mocks.getAccessToken).toHaveBeenCalledWith({
+ body: {
+ providerId: "google-analytics",
+ userId: "user_1",
+ accountId: "account_1",
+ },
+ });
+ expect(mocks.fetch).toHaveBeenCalledWith(
+ "https://analyticsdata.googleapis.com/v1beta/properties/123:runReport",
+ expect.objectContaining({
+ method: "POST",
+ body: JSON.stringify(reportRequest),
+ }),
+ );
+ });
+
+ it("reuses one token promise for concurrent reports on the same client", async () => {
+ mocks.fetch.mockImplementation(async () =>
+ Response.json({
+ dimensionHeaders: [{ name: "hostName" }],
+ metricHeaders: [{ name: "sessions", type: "TYPE_INTEGER" }],
+ rowCount: 0,
+ }),
+ );
+ const client = createGa4DataClient({
+ userId: "user_1",
+ ga4AccountId: "account_1",
+ propertyId: "properties/123",
+ });
+
+ await Promise.all([
+ client.runReport(reportRequest),
+ client.runReport(reportRequest),
+ ]);
+
+ expect(mocks.fetch).toHaveBeenCalledTimes(2);
+ expect(mocks.getAccessToken).toHaveBeenCalledTimes(1);
+ });
+
+ it("classifies quota failures and retains a safe retry delay", async () => {
+ mocks.fetch.mockResolvedValue(
+ new Response('{"error":{"message":"private upstream detail"}}', {
+ status: 429,
+ headers: { "retry-after": "120" },
+ }),
+ );
+ const promise = createGa4DataClient({
+ userId: "user_1",
+ ga4AccountId: "account_1",
+ propertyId: "properties/123",
+ }).runReport(reportRequest);
+
+ await expect(promise).rejects.toBeInstanceOf(Ga4DataApiError);
+ await expect(promise).rejects.toMatchObject({
+ status: 429,
+ retryAfterSeconds: 120,
+ });
+ });
+
+ it("retains only safe Google error categories from a rejected request", async () => {
+ mocks.fetch.mockResolvedValue(
+ Response.json(
+ {
+ error: {
+ message: "contains project-specific private detail",
+ status: "PERMISSION_DENIED",
+ details: [
+ {
+ reason: "SERVICE_DISABLED",
+ metadata: { service: "analyticsdata.googleapis.com" },
+ },
+ ],
+ },
+ },
+ { status: 403 },
+ ),
+ );
+ await expect(
+ createGa4DataClient({
+ userId: "user_1",
+ ga4AccountId: "account_1",
+ propertyId: "properties/123",
+ }).runReport(reportRequest),
+ ).rejects.toMatchObject({
+ status: 403,
+ upstreamReason: "SERVICE_DISABLED",
+ });
+ });
+
+ it("rejects malformed successful responses", async () => {
+ mocks.fetch.mockResolvedValue(Response.json({ rows: "not-an-array" }));
+ await expect(
+ createGa4DataClient({
+ userId: "user_1",
+ ga4AccountId: "account_1",
+ propertyId: "properties/123",
+ }).runReport(reportRequest),
+ ).rejects.toBeInstanceOf(Ga4MalformedResponseError);
+ });
+
+ it("rejects a non-canonical property identifier before fetching", async () => {
+ expect(() =>
+ createGa4DataClient({
+ userId: "user_1",
+ ga4AccountId: "account_1",
+ propertyId: "123",
+ }),
+ ).toThrow();
+ expect(mocks.fetch).not.toHaveBeenCalled();
+ });
+
+ it("converts transport failures to a typed upstream error", async () => {
+ mocks.fetch.mockRejectedValue(new TypeError("DNS failure"));
+ await expect(
+ createGa4DataClient({
+ userId: "user_1",
+ ga4AccountId: "account_1",
+ propertyId: "properties/123",
+ }).runReport(reportRequest),
+ ).rejects.toMatchObject({ status: 0 });
+ });
+});
diff --git a/src/server/lib/ga4Client.ts b/src/server/lib/ga4Client.ts
new file mode 100644
index 0000000..7e9df6b
--- /dev/null
+++ b/src/server/lib/ga4Client.ts
@@ -0,0 +1,480 @@
+/* eslint-disable max-lines -- one client module per Google integration (gscClient precedent); GA4 spans the Admin and Data APIs */
+import { z } from "zod";
+import { getAuth } from "@/lib/auth";
+import {
+ Ga4AdminApiError,
+ Ga4DataApiError,
+ Ga4MalformedResponseError,
+ Ga4TokenError,
+} from "@/server/lib/ga4Errors";
+import { GA4_OAUTH_PROVIDER_ID } from "@/shared/ga4";
+
+const GA4_ADMIN_API_BASE = "https://analyticsadmin.googleapis.com/v1beta";
+const GA4_ADMIN_ALPHA_API_BASE =
+ "https://analyticsadmin.googleapis.com/v1alpha";
+const GOOGLE_USERINFO_URL = "https://openidconnect.googleapis.com/v1/userinfo";
+const GA4_DATA_API_BASE = "https://analyticsdata.googleapis.com/v1beta";
+const MAX_ACCOUNT_SUMMARY_PAGES = 100;
+const MAX_ERROR_BODY_LENGTH = 8_000;
+const propertyIdSchema = z.string().regex(/^properties\/\d+$/);
+const dataStreamNameSchema = z
+ .string()
+ .regex(/^properties\/\d+\/dataStreams\/\d+$/);
+
+const propertySummarySchema = z.object({
+ property: propertyIdSchema,
+ displayName: z.string(),
+});
+
+const accountSummarySchema = z.object({
+ account: z.string().regex(/^accounts\/\d+$/),
+ displayName: z.string(),
+ propertySummaries: z.array(propertySummarySchema).optional(),
+});
+
+const accountSummariesResponseSchema = z.object({
+ accountSummaries: z.array(accountSummarySchema).optional(),
+ nextPageToken: z.string().optional(),
+});
+
+const propertySchema = z.object({
+ name: propertyIdSchema,
+ displayName: z.string(),
+ timeZone: z.string().min(1),
+ currencyCode: z.string().min(1),
+});
+
+const dataStreamSchema = z.object({
+ name: dataStreamNameSchema,
+ type: z.string(),
+ displayName: z.string().default(""),
+ createTime: z.string().optional(),
+ updateTime: z.string().optional(),
+ webStreamData: z
+ .object({
+ measurementId: z.string().optional(),
+ defaultUri: z.string().optional(),
+ })
+ .optional(),
+ androidAppStreamData: z
+ .object({ packageName: z.string().optional() })
+ .optional(),
+ iosAppStreamData: z.object({ bundleId: z.string().optional() }).optional(),
+});
+const dataStreamsResponseSchema = z.object({
+ dataStreams: z.array(dataStreamSchema).optional(),
+ nextPageToken: z.string().optional(),
+});
+const enhancedMeasurementSettingsSchema = z.object({
+ streamEnabled: z.boolean(),
+ scrollsEnabled: z.boolean(),
+ outboundClicksEnabled: z.boolean(),
+ siteSearchEnabled: z.boolean(),
+ videoEngagementEnabled: z.boolean(),
+ fileDownloadsEnabled: z.boolean(),
+ pageChangesEnabled: z.boolean(),
+ formInteractionsEnabled: z.boolean(),
+ searchQueryParameter: z.string(),
+ uriQueryParameter: z.string().optional().default(""),
+});
+const keyEventSchema = z.object({
+ eventName: z.string(),
+ createTime: z.string().optional(),
+ deletable: z.boolean().optional(),
+ custom: z.boolean().optional(),
+ countingMethod: z.string(),
+ defaultValue: z
+ .object({ numericValue: z.number(), currencyCode: z.string() })
+ .optional(),
+});
+const keyEventsResponseSchema = z.object({
+ keyEvents: z.array(keyEventSchema).optional(),
+ nextPageToken: z.string().optional(),
+});
+const customDimensionSchema = z.object({
+ parameterName: z.string(),
+ displayName: z.string(),
+ description: z.string().optional().default(""),
+ scope: z.string(),
+ disallowAdsPersonalization: z.boolean().optional().default(false),
+});
+const customDimensionsResponseSchema = z.object({
+ customDimensions: z.array(customDimensionSchema).optional(),
+ nextPageToken: z.string().optional(),
+});
+const customMetricSchema = z.object({
+ parameterName: z.string(),
+ displayName: z.string(),
+ description: z.string().optional().default(""),
+ measurementUnit: z.string(),
+ scope: z.string(),
+ restrictedMetricType: z.array(z.string()).optional().default([]),
+});
+const customMetricsResponseSchema = z.object({
+ customMetrics: z.array(customMetricSchema).optional(),
+ nextPageToken: z.string().optional(),
+});
+
+type Ga4PropertySummary = {
+ propertyId: string;
+ displayName: string;
+ accountDisplayName: string;
+};
+
+type Ga4Property = z.infer;
+
+async function getGa4AccessToken(opts: {
+ userId: string;
+ ga4AccountId: string;
+}): Promise {
+ let result: { accessToken?: string } | undefined;
+ try {
+ result = await getAuth().api.getAccessToken({
+ body: {
+ providerId: GA4_OAUTH_PROVIDER_ID,
+ userId: opts.userId,
+ accountId: opts.ga4AccountId,
+ },
+ });
+ } catch (error) {
+ throw new Ga4TokenError(
+ "Could not mint a Google Analytics access token.",
+ error,
+ );
+ }
+ if (!result?.accessToken) {
+ throw new Ga4TokenError("Google Analytics returned no access token.");
+ }
+ return result.accessToken;
+}
+
+function adminMessageForStatus(status: number): string {
+ if (status === 401) return "Google Analytics connection expired.";
+ if (status === 403) {
+ return "Google Analytics denied access. Check the account's property access and enabled APIs.";
+ }
+ if (status === 429) return "Google Analytics rate limit reached.";
+ return `Google Analytics Admin API error (${status}).`;
+}
+
+function isAbortError(error: unknown): boolean {
+ return error instanceof Error && error.name === "AbortError";
+}
+
+function memoizedGa4AccessToken(opts: {
+ userId: string;
+ ga4AccountId: string;
+}) {
+ let accessTokenPromise: Promise | undefined;
+ return () => (accessTokenPromise ??= getGa4AccessToken(opts));
+}
+
+/** Read-only Admin API client used only for account/property discovery. */
+export function createGa4AdminClient(opts: {
+ userId: string;
+ ga4AccountId: string;
+}) {
+ const accessToken = memoizedGa4AccessToken(opts);
+
+ async function request(url: string): Promise {
+ const token = await accessToken();
+ let response: Response;
+ try {
+ response = await fetch(url, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ } catch (error) {
+ if (isAbortError(error)) throw error;
+ throw new Ga4AdminApiError(
+ 0,
+ "Google Analytics Admin API is temporarily unavailable.",
+ );
+ }
+ if (!response.ok) {
+ throw new Ga4AdminApiError(
+ response.status,
+ adminMessageForStatus(response.status),
+ );
+ }
+ return response.json();
+ }
+
+ function propertyUrl(base: string, propertyId: string, child: string): URL {
+ const canonicalId = propertyIdSchema.parse(propertyId);
+ return new URL(`${base}/${canonicalId}/${child}`);
+ }
+
+ return {
+ async getUserInfoEmail(): Promise {
+ const data = z
+ .object({ email: z.string().email().optional() })
+ .parse(await request(GOOGLE_USERINFO_URL));
+ return data.email ?? null;
+ },
+
+ async listProperties(): Promise {
+ const properties: Ga4PropertySummary[] = [];
+ let pageToken: string | undefined;
+
+ for (let page = 0; page < MAX_ACCOUNT_SUMMARY_PAGES; page += 1) {
+ const url = new URL(`${GA4_ADMIN_API_BASE}/accountSummaries`);
+ url.searchParams.set("pageSize", "200");
+ if (pageToken) url.searchParams.set("pageToken", pageToken);
+ const response = accountSummariesResponseSchema.parse(
+ await request(url.toString()),
+ );
+ for (const account of response.accountSummaries ?? []) {
+ for (const property of account.propertySummaries ?? []) {
+ properties.push({
+ propertyId: property.property,
+ displayName: property.displayName,
+ accountDisplayName: account.displayName,
+ });
+ }
+ }
+
+ pageToken = response.nextPageToken || undefined;
+ if (!pageToken) return properties;
+ }
+
+ throw new Error(
+ "Google Analytics property discovery exceeded 100 pages.",
+ );
+ },
+
+ async getProperty(propertyId: string): Promise {
+ const canonicalId = propertyIdSchema.parse(propertyId);
+ return propertySchema.parse(
+ await request(`${GA4_ADMIN_API_BASE}/${canonicalId}`),
+ );
+ },
+
+ async listDataStreams(propertyId: string) {
+ const url = propertyUrl(
+ GA4_ADMIN_ALPHA_API_BASE,
+ propertyId,
+ "dataStreams",
+ );
+ url.searchParams.set("pageSize", "200");
+ const response = dataStreamsResponseSchema.parse(
+ await request(url.toString()),
+ );
+ return response.dataStreams ?? [];
+ },
+
+ async getEnhancedMeasurementSettings(streamName: string) {
+ const canonicalName = dataStreamNameSchema.parse(streamName);
+ return enhancedMeasurementSettingsSchema.parse(
+ await request(
+ `${GA4_ADMIN_ALPHA_API_BASE}/${canonicalName}/enhancedMeasurementSettings`,
+ ),
+ );
+ },
+
+ async listKeyEvents(propertyId: string) {
+ const url = propertyUrl(GA4_ADMIN_API_BASE, propertyId, "keyEvents");
+ url.searchParams.set("pageSize", "200");
+ const response = keyEventsResponseSchema.parse(
+ await request(url.toString()),
+ );
+ return response.keyEvents ?? [];
+ },
+
+ async listCustomDimensions(propertyId: string) {
+ const url = propertyUrl(
+ GA4_ADMIN_API_BASE,
+ propertyId,
+ "customDimensions",
+ );
+ url.searchParams.set("pageSize", "200");
+ const response = customDimensionsResponseSchema.parse(
+ await request(url.toString()),
+ );
+ return response.customDimensions ?? [];
+ },
+
+ async listCustomMetrics(propertyId: string) {
+ const url = propertyUrl(GA4_ADMIN_API_BASE, propertyId, "customMetrics");
+ url.searchParams.set("pageSize", "200");
+ const response = customMetricsResponseSchema.parse(
+ await request(url.toString()),
+ );
+ return response.customMetrics ?? [];
+ },
+ };
+}
+
+const quotaStatusSchema = z.object({
+ consumed: z.number().int(),
+ remaining: z.number().int(),
+});
+
+const propertyQuotaSchema = z.object({
+ tokensPerDay: quotaStatusSchema.optional(),
+ tokensPerHour: quotaStatusSchema.optional(),
+ concurrentRequests: quotaStatusSchema.optional(),
+ serverErrorsPerProjectPerHour: quotaStatusSchema.optional(),
+ potentiallyThresholdedRequestsPerHour: quotaStatusSchema.optional(),
+ tokensPerProjectPerHour: quotaStatusSchema.optional(),
+});
+
+const responseMetadataSchema = z.object({
+ dataLossFromOtherRow: z.boolean().optional(),
+ samplingMetadatas: z
+ .array(
+ z.object({
+ samplesReadCount: z.string(),
+ samplingSpaceSize: z.string(),
+ }),
+ )
+ .optional(),
+ schemaRestrictionResponse: z
+ .object({
+ activeMetricRestrictions: z
+ .array(
+ z.object({
+ metricName: z.string(),
+ restrictedMetricTypes: z.array(z.string()).optional(),
+ }),
+ )
+ .optional(),
+ })
+ .optional(),
+ currencyCode: z.string().optional(),
+ timeZone: z.string().optional(),
+ emptyReason: z.string().optional(),
+ subjectToThresholding: z.boolean().optional(),
+});
+
+const runReportResponseSchema = z.object({
+ dimensionHeaders: z.array(z.object({ name: z.string() })).optional(),
+ metricHeaders: z
+ .array(z.object({ name: z.string(), type: z.string().optional() }))
+ .optional(),
+ rows: z
+ .array(
+ z.object({
+ dimensionValues: z.array(z.object({ value: z.string() })).optional(),
+ metricValues: z.array(z.object({ value: z.string() })).optional(),
+ }),
+ )
+ .optional(),
+ rowCount: z.number().int().nonnegative().optional(),
+ metadata: responseMetadataSchema.optional(),
+ propertyQuota: propertyQuotaSchema.optional(),
+ kind: z.string().optional(),
+});
+
+const googleErrorSchema = z.object({
+ error: z.object({
+ details: z
+ .array(
+ z.object({
+ reason: z.string().optional(),
+ metadata: z.object({ service: z.string().optional() }).optional(),
+ }),
+ )
+ .optional(),
+ }),
+});
+
+export type Ga4RunReportResponse = z.infer;
+
+export type Ga4RunReportRequest = {
+ dateRanges: Array<{ startDate: string; endDate: string }>;
+ dimensions: Array<{ name: string }>;
+ metrics: Array<{ name: string }>;
+ dimensionFilter?: unknown;
+ metricFilter?: unknown;
+ offset: string;
+ limit: string;
+ orderBys: Array<{
+ metric?: { metricName: string };
+ dimension?: { dimensionName: string };
+ desc?: boolean;
+ }>;
+ keepEmptyRows: false;
+ returnPropertyQuota: true;
+};
+
+function safeRetryAfter(response: Response): number | null {
+ const value = response.headers.get("retry-after");
+ if (!value || !/^\d+$/.test(value)) return null;
+ return Math.min(Number(value), 86_400);
+}
+
+function dataMessageForStatus(status: number): string {
+ if (status === 400) return "Google Analytics rejected this report.";
+ if (status === 401) return "Google Analytics connection expired.";
+ if (status === 403) return "Google Analytics denied access to this property.";
+ if (status === 429) return "Google Analytics reporting quota was exhausted.";
+ return "Google Analytics reporting is temporarily unavailable.";
+}
+
+export function createGa4DataClient(opts: {
+ userId: string;
+ ga4AccountId: string;
+ propertyId: string;
+}) {
+ const propertyId = propertyIdSchema.parse(opts.propertyId);
+ const accessToken = memoizedGa4AccessToken(opts);
+
+ return {
+ async runReport(
+ request: Ga4RunReportRequest,
+ ): Promise {
+ const token = await accessToken();
+ let response: Response;
+ try {
+ response = await fetch(`${GA4_DATA_API_BASE}/${propertyId}:runReport`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(request),
+ });
+ } catch (error) {
+ if (isAbortError(error)) throw error;
+ throw new Ga4DataApiError(
+ 0,
+ "Google Analytics reporting is temporarily unavailable.",
+ );
+ }
+ if (!response.ok) {
+ const body = await response
+ .text()
+ .then((responseBody) => responseBody.slice(0, MAX_ERROR_BODY_LENGTH))
+ .catch(() => "");
+ let upstreamReason: string | null = null;
+ try {
+ const parsed = googleErrorSchema.safeParse(JSON.parse(body));
+ if (parsed.success) {
+ upstreamReason =
+ parsed.data.error.details?.find(
+ (detail) =>
+ detail.metadata?.service === "analyticsdata.googleapis.com",
+ )?.reason ??
+ parsed.data.error.details?.find((detail) => detail.reason)
+ ?.reason ??
+ null;
+ }
+ } catch {
+ // Non-JSON error pages intentionally collapse to status-only errors.
+ }
+ throw new Ga4DataApiError(
+ response.status,
+ dataMessageForStatus(response.status),
+ safeRetryAfter(response),
+ upstreamReason,
+ );
+ }
+
+ try {
+ return runReportResponseSchema.parse(await response.json());
+ } catch {
+ throw new Ga4MalformedResponseError();
+ }
+ },
+ };
+}
diff --git a/src/server/lib/ga4Errors.ts b/src/server/lib/ga4Errors.ts
new file mode 100644
index 0000000..5647d56
--- /dev/null
+++ b/src/server/lib/ga4Errors.ts
@@ -0,0 +1,59 @@
+export class Ga4AdminApiError extends Error {
+ constructor(
+ public readonly status: number,
+ message: string,
+ ) {
+ super(message);
+ this.name = "Ga4AdminApiError";
+ }
+}
+
+export class Ga4TokenError extends Error {
+ constructor(
+ message: string,
+ public readonly cause?: unknown,
+ ) {
+ super(message);
+ this.name = "Ga4TokenError";
+ }
+}
+
+export class Ga4DataApiError extends Error {
+ constructor(
+ public readonly status: number,
+ message: string,
+ public readonly retryAfterSeconds: number | null = null,
+ public readonly upstreamReason: string | null = null,
+ ) {
+ super(message);
+ this.name = "Ga4DataApiError";
+ }
+}
+
+export class Ga4MalformedResponseError extends Error {
+ constructor() {
+ super("Google Analytics returned an invalid reporting response.");
+ this.name = "Ga4MalformedResponseError";
+ }
+}
+
+type Ga4ReportErrorCode =
+ | "validation_error"
+ | "ga4_not_connected"
+ | "ga4_reconnect_required"
+ | "ga4_property_inaccessible"
+ | "ga4_report_incompatible"
+ | "ga4_quota_exhausted"
+ | "ga4_upstream_unavailable"
+ | "ga4_malformed_response";
+
+export class Ga4ReportError extends Error {
+ constructor(
+ public readonly code: Ga4ReportErrorCode,
+ message: string,
+ public readonly retryAfterSeconds: number | null = null,
+ ) {
+ super(message);
+ this.name = "Ga4ReportError";
+ }
+}
diff --git a/src/server/lib/gscClient.ts b/src/server/lib/gscClient.ts
index 1cd3495..9bbe273 100644
--- a/src/server/lib/gscClient.ts
+++ b/src/server/lib/gscClient.ts
@@ -1,33 +1,13 @@
import { getAuth } from "@/lib/auth";
import { GSC_OAUTH_PROVIDER_ID } from "@/shared/gsc";
+import { GscApiError, GscTokenError } from "./gscErrors";
+
+export { GscApiError, GscTokenError } from "./gscErrors";
const GSC_API_BASE = "https://www.googleapis.com/webmasters/v3";
const GOOGLE_USERINFO_URL = "https://openidconnect.googleapis.com/v1/userinfo";
/** A GSC REST call returned a non-2xx status. `status` drives user-facing messaging. */
-export class GscApiError extends Error {
- constructor(
- public readonly status: number,
- message: string,
- public readonly body?: string,
- ) {
- super(message);
- this.name = "GscApiError";
- }
-}
-
-/** No fresh access token could be minted — the user revoked the grant, or the
- * refresh token expired (e.g. weekly in Google's OAuth "Testing" mode). */
-export class GscTokenError extends Error {
- constructor(
- message: string,
- public readonly cause?: unknown,
- ) {
- super(message);
- this.name = "GscTokenError";
- }
-}
-
export type GscSite = {
siteUrl: string;
permissionLevel: string;
diff --git a/src/server/lib/gscErrors.ts b/src/server/lib/gscErrors.ts
new file mode 100644
index 0000000..42a29a3
--- /dev/null
+++ b/src/server/lib/gscErrors.ts
@@ -0,0 +1,27 @@
+export class GscApiError extends Error {
+ constructor(
+ public readonly status: number,
+ message: string,
+ public readonly body?: string,
+ ) {
+ super(message);
+ this.name = "GscApiError";
+ }
+}
+
+export class GscTokenError extends Error {
+ constructor(
+ message: string,
+ public readonly cause?: unknown,
+ ) {
+ super(message);
+ this.name = "GscTokenError";
+ }
+}
+
+export class GscNotConnectedError extends Error {
+ constructor(public readonly projectId: string) {
+ super("Search Console is not connected for this project");
+ this.name = "GscNotConnectedError";
+ }
+}
diff --git a/src/server/mcp/instrumentation.test.ts b/src/server/mcp/instrumentation.test.ts
index bcf0881..9d20212 100644
--- a/src/server/mcp/instrumentation.test.ts
+++ b/src/server/mcp/instrumentation.test.ts
@@ -1,6 +1,7 @@
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { z } from "zod";
+import { instrumentMcpToolHandler } from "./instrumentation";
import {
runWithMcpToolAuthContext,
type McpToolAuthContext,
@@ -60,15 +61,9 @@ const authContext: McpToolAuthContext = {
};
describe("instrumentMcpToolHandler", () => {
- beforeEach(() => {
- mocks.captureServerError.mockReset();
- mocks.captureServerEvent.mockReset();
- mocks.recordExternalMcpToolCall.mockReset();
- mocks.incrementSelfHostMcpToolCallCount.mockReset();
- });
+ beforeEach(() => {});
it("passes a valid result through without reporting", async () => {
- const { instrumentMcpToolHandler } = await import("./instrumentation");
const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () =>
okResult({ items: [{ domain: "example.com" }] }),
);
@@ -82,7 +77,6 @@ describe("instrumentMcpToolHandler", () => {
});
it("reports an output schema mismatch the SDK would silently reject", async () => {
- const { instrumentMcpToolHandler } = await import("./instrumentation");
const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () =>
okResult({ items: "not-an-array" }),
);
@@ -97,7 +91,6 @@ describe("instrumentMcpToolHandler", () => {
});
it("reports and rethrows a reportable handler error", async () => {
- const { instrumentMcpToolHandler } = await import("./instrumentation");
const boom = new Error("upstream exploded");
const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () => {
throw boom;
@@ -109,7 +102,6 @@ describe("instrumentMcpToolHandler", () => {
});
it("rethrows expected errors without reporting them", async () => {
- const { instrumentMcpToolHandler } = await import("./instrumentation");
const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () => {
throw new AppError("NOT_FOUND");
});
@@ -119,7 +111,6 @@ describe("instrumentMcpToolHandler", () => {
});
it("captures a usage event when auth context is present", async () => {
- const { instrumentMcpToolHandler } = await import("./instrumentation");
const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () =>
okResult({ items: [] }),
);
@@ -142,7 +133,6 @@ describe("instrumentMcpToolHandler", () => {
});
it("marks schema-rejected results as failed usage", async () => {
- const { instrumentMcpToolHandler } = await import("./instrumentation");
const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () =>
okResult({ items: "not-an-array" }),
);
@@ -155,8 +145,43 @@ describe("instrumentMcpToolHandler", () => {
});
});
+ it("marks a structured tool error as failed usage without recording activation", async () => {
+ const schema = z.object({
+ status: z.enum(["ok", "error"]),
+ error: z.object({ code: z.string() }).optional(),
+ });
+ const wrapped = instrumentMcpToolHandler("demo", schema, async () =>
+ okResult({ status: "error", error: { code: "ga4_not_connected" } }),
+ );
+
+ await runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra));
+
+ expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({
+ event: "mcp:tool_call",
+ properties: { success: false, error_code: "ga4_not_connected" },
+ });
+ expect(mocks.recordExternalMcpToolCall).not.toHaveBeenCalled();
+ });
+
+ it("marks an ok-false tool result as failed usage", async () => {
+ const schema = z.object({
+ ok: z.boolean(),
+ reason: z.string().optional(),
+ });
+ const wrapped = instrumentMcpToolHandler("demo", schema, async () =>
+ okResult({ ok: false, reason: "audit_not_ready" }),
+ );
+
+ await runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra));
+
+ expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({
+ event: "mcp:tool_call",
+ properties: { success: false, error_code: "audit_not_ready" },
+ });
+ expect(mocks.recordExternalMcpToolCall).not.toHaveBeenCalled();
+ });
+
it("captures a failed usage event with the error code", async () => {
- const { instrumentMcpToolHandler } = await import("./instrumentation");
const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () => {
throw new AppError("NOT_FOUND");
});
@@ -172,7 +197,6 @@ describe("instrumentMcpToolHandler", () => {
});
it("skips the usage event when auth context is missing", async () => {
- const { instrumentMcpToolHandler } = await import("./instrumentation");
const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () =>
okResult({ items: [] }),
);
@@ -184,7 +208,6 @@ describe("instrumentMcpToolHandler", () => {
});
it("records the activation milestone for a successful external call", async () => {
- const { instrumentMcpToolHandler } = await import("./instrumentation");
const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () =>
okResult({ items: [] }),
);
@@ -197,7 +220,6 @@ describe("instrumentMcpToolHandler", () => {
});
it("skips the activation milestone for first-party (null clientId) calls", async () => {
- const { instrumentMcpToolHandler } = await import("./instrumentation");
const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () =>
okResult({ items: [] }),
);
@@ -210,7 +232,6 @@ describe("instrumentMcpToolHandler", () => {
});
it("skips the activation milestone when the call fails", async () => {
- const { instrumentMcpToolHandler } = await import("./instrumentation");
const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () => {
throw new AppError("NOT_FOUND");
});
diff --git a/src/server/mcp/instrumentation.ts b/src/server/mcp/instrumentation.ts
index c297f08..c98ea4a 100644
--- a/src/server/mcp/instrumentation.ts
+++ b/src/server/mcp/instrumentation.ts
@@ -19,6 +19,10 @@ type ToolHandler = (
extra: ToolExtra,
) => CallToolResult | Promise;
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
+
/**
* Usage analytics for every MCP tool invocation. `clientId` distinguishes
* external MCP clients (OAuth) from the in-app agent (first-party auth, null
@@ -29,7 +33,14 @@ type ToolHandler = (
function captureMcpToolCall(
toolName: string,
extra: ToolExtra,
- outcome: { success: boolean; errorCode?: string },
+ outcome: {
+ success: boolean;
+ errorCode?: string;
+ durationMs?: number;
+ projectId?: string;
+ rowCount?: number;
+ quotaRemaining?: number;
+ },
) {
waitUntil(incrementSelfHostMcpToolCallCount());
@@ -46,6 +57,10 @@ function captureMcpToolCall(
error_code: outcome.errorCode,
client_id: auth.clientId,
source: auth.clientId ? "mcp_client" : "in_app_agent",
+ duration_ms: outcome.durationMs,
+ project_id: outcome.projectId,
+ row_count: outcome.rowCount,
+ quota_remaining: outcome.quotaRemaining,
},
}),
);
@@ -76,6 +91,7 @@ export function instrumentMcpToolHandler(
const normalizedOutputSchema = normalizeObjectSchema(outputSchema);
return async (args, extra) => {
+ const startedAt = performance.now();
try {
const result = await handler(args, extra);
// The SDK converts an output-schema mismatch into a client-visible
@@ -108,19 +124,61 @@ export function instrumentMcpToolHandler(
);
}
}
+ const structured = result.structuredContent;
+ const returnedFailure =
+ structured?.status === "error" || structured?.ok === false;
+ const returnedError =
+ structured?.status === "error" && isRecord(structured.error)
+ ? structured.error.code
+ : undefined;
+ const returnedReason =
+ structured?.ok === false ? structured.reason : undefined;
+ const meta = isRecord(structured?.meta) ? structured.meta : undefined;
+ const quota = isRecord(structured?.quota) ? structured.quota : undefined;
+ const tokensPerDay = isRecord(quota?.tokensPerDay)
+ ? quota.tokensPerDay
+ : undefined;
+ const returnedFailureCode =
+ typeof returnedError === "string"
+ ? returnedError
+ : typeof returnedReason === "string"
+ ? returnedReason
+ : undefined;
+ const succeeded =
+ !result.isError && !outputValidationFailed && !returnedFailure;
captureMcpToolCall(
toolName,
extra,
outputValidationFailed
- ? { success: false, errorCode: "MCP_OUTPUT_VALIDATION" }
- : { success: !result.isError },
+ ? {
+ success: false,
+ errorCode: "MCP_OUTPUT_VALIDATION",
+ durationMs: Math.round(performance.now() - startedAt),
+ }
+ : {
+ success: succeeded,
+ errorCode: returnedFailureCode,
+ durationMs: Math.round(performance.now() - startedAt),
+ projectId:
+ typeof meta?.projectId === "string"
+ ? meta.projectId
+ : undefined,
+ rowCount:
+ typeof structured?.rowCount === "number"
+ ? structured.rowCount
+ : undefined,
+ quotaRemaining:
+ typeof tokensPerDay?.remaining === "number"
+ ? tokensPerDay.remaining
+ : undefined,
+ },
);
// Dashboard activation milestone: a successful call from an external
// MCP client (OAuth clientId; SAM and the self-hosted transport are
// first-party with clientId null). Awaited so the write stays inside
// the request's DB scope; a per-isolate memo keeps this off the hot
// path after the first call.
- if (!result.isError && !outputValidationFailed) {
+ if (succeeded) {
try {
const auth = getAuth(extra);
if (auth.clientId) {
@@ -136,6 +194,7 @@ export function instrumentMcpToolHandler(
captureMcpToolCall(toolName, extra, {
success: false,
errorCode: appError?.code ?? "INTERNAL_ERROR",
+ durationMs: Math.round(performance.now() - startedAt),
});
if (shouldCaptureAppErrorCode(appError?.code)) {
console.error(`mcp.tool error (${toolName}):`, error);
diff --git a/src/server/mcp/server.ts b/src/server/mcp/server.ts
index 9420d6d..bafd6ad 100644
--- a/src/server/mcp/server.ts
+++ b/src/server/mcp/server.ts
@@ -1,14 +1,39 @@
-import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import type {
+ McpServer,
+ ToolCallback,
+} from "@modelcontextprotocol/sdk/server/mcp.js";
+import type {
+ AnySchema,
+ ZodRawShapeCompat,
+} from "@modelcontextprotocol/sdk/server/zod-compat.js";
+import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
import { instrumentMcpToolHandler } from "@/server/mcp/instrumentation";
import { getBacklinksOverviewTool } from "@/server/mcp/tools/get-backlinks-overview";
import { getBacklinksProfileTool } from "@/server/mcp/tools/get-backlinks-profile";
import { getDomainKeywordSuggestionsTool } from "@/server/mcp/tools/get-domain-keyword-suggestions";
import { getDomainOverviewTool } from "@/server/mcp/tools/get-domain-overview";
+import { addRankTrackingKeywordsTool } from "@/server/mcp/tools/add-rank-tracking-keywords";
+import { createRankTrackerTool } from "@/server/mcp/tools/create-rank-tracker";
+import { estimateRankTrackerCostTool } from "@/server/mcp/tools/estimate-rank-tracker-cost";
import { getRankTrackerTool } from "@/server/mcp/tools/get-rank-tracker";
+import { removeRankTrackingKeywordsTool } from "@/server/mcp/tools/remove-rank-tracking-keywords";
+import { runRankTrackerTool } from "@/server/mcp/tools/run-rank-tracker";
import { getSerpResultsTool } from "@/server/mcp/tools/get-serp-results";
import { createProjectTool } from "@/server/mcp/tools/create-project";
import { listProjectsTool } from "@/server/mcp/tools/list-projects";
import { listSavedKeywordsTool } from "@/server/mcp/tools/list-saved-keywords";
+import {
+ getGoogleAnalyticsAudienceBreakdownTool,
+ getGoogleAnalyticsEcommercePerformanceTool,
+ getGoogleAnalyticsKeyEventsTool,
+ getGoogleAnalyticsMeasurementHealthTool,
+ getGoogleAnalyticsOrganicLandingPagesTool,
+ getGoogleAnalyticsOrganicOverviewTool,
+ getGoogleAnalyticsPagePerformanceTool,
+ getGoogleAnalyticsSiteSearchTool,
+ getGoogleAnalyticsTrafficAcquisitionTool,
+ getSearchOpportunitiesTool,
+} from "@/server/mcp/tools/google-analytics-tools";
import {
findSerpCompetitorsTool,
getGoogleBusinessQuestionsTool,
@@ -31,226 +56,75 @@ import {
} from "@/server/mcp/tools/site-audit-tools";
import { whoamiTool } from "@/server/mcp/tools/whoami";
-// Each handler is wrapped with instrumentMcpToolHandler so failures reach
-// PostHog — the MCP route has no error middleware of its own. Tools are
-// registered one explicit call at a time (not via a loop/helper) so each one's
-// input/output schema types stay concrete, which the SDK's registerTool
-// generics require to type the handler callback.
-export function registerOpenSeoMcpTools(server: McpServer) {
+// Each handler is wrapped so failures reach PostHog because the MCP route has
+// no error middleware of its own.
+function registerInstrumentedTool<
+ In extends ZodRawShapeCompat | AnySchema,
+ Out extends ZodRawShapeCompat | AnySchema,
+>(
+ server: McpServer,
+ tool: {
+ name: string;
+ config: {
+ inputSchema?: In;
+ outputSchema?: Out;
+ title?: string;
+ description?: string;
+ annotations?: ToolAnnotations;
+ };
+ handler: ToolCallback;
+ },
+) {
server.registerTool(
- whoamiTool.name,
- whoamiTool.config,
+ tool.name,
+ tool.config,
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- instrumentation preserves the callback arguments validated by ToolCallback
instrumentMcpToolHandler(
- whoamiTool.name,
- whoamiTool.config.outputSchema,
- whoamiTool.handler,
- ),
- );
- server.registerTool(
- listProjectsTool.name,
- listProjectsTool.config,
- instrumentMcpToolHandler(
- listProjectsTool.name,
- listProjectsTool.config.outputSchema,
- listProjectsTool.handler,
- ),
- );
- server.registerTool(
- createProjectTool.name,
- createProjectTool.config,
- instrumentMcpToolHandler(
- createProjectTool.name,
- createProjectTool.config.outputSchema,
- createProjectTool.handler,
- ),
- );
- server.registerTool(
- listSavedKeywordsTool.name,
- listSavedKeywordsTool.config,
- instrumentMcpToolHandler(
- listSavedKeywordsTool.name,
- listSavedKeywordsTool.config.outputSchema,
- listSavedKeywordsTool.handler,
- ),
- );
- server.registerTool(
- researchKeywordsTool.name,
- researchKeywordsTool.config,
- instrumentMcpToolHandler(
- researchKeywordsTool.name,
- researchKeywordsTool.config.outputSchema,
- researchKeywordsTool.handler,
- ),
- );
- server.registerTool(
- saveKeywordsTool.name,
- saveKeywordsTool.config,
- instrumentMcpToolHandler(
- saveKeywordsTool.name,
- saveKeywordsTool.config.outputSchema,
- saveKeywordsTool.handler,
- ),
- );
- server.registerTool(
- getDomainOverviewTool.name,
- getDomainOverviewTool.config,
- instrumentMcpToolHandler(
- getDomainOverviewTool.name,
- getDomainOverviewTool.config.outputSchema,
- getDomainOverviewTool.handler,
- ),
- );
- server.registerTool(
- getDomainKeywordSuggestionsTool.name,
- getDomainKeywordSuggestionsTool.config,
- instrumentMcpToolHandler(
- getDomainKeywordSuggestionsTool.name,
- getDomainKeywordSuggestionsTool.config.outputSchema,
- getDomainKeywordSuggestionsTool.handler,
- ),
- );
- server.registerTool(
- getBacklinksOverviewTool.name,
- getBacklinksOverviewTool.config,
- instrumentMcpToolHandler(
- getBacklinksOverviewTool.name,
- getBacklinksOverviewTool.config.outputSchema,
- getBacklinksOverviewTool.handler,
- ),
- );
- server.registerTool(
- getBacklinksProfileTool.name,
- getBacklinksProfileTool.config,
- instrumentMcpToolHandler(
- getBacklinksProfileTool.name,
- getBacklinksProfileTool.config.outputSchema,
- getBacklinksProfileTool.handler,
- ),
- );
- server.registerTool(
- getSerpResultsTool.name,
- getSerpResultsTool.config,
- instrumentMcpToolHandler(
- getSerpResultsTool.name,
- getSerpResultsTool.config.outputSchema,
- getSerpResultsTool.handler,
- ),
- );
- server.registerTool(
- getRankTrackerTool.name,
- getRankTrackerTool.config,
- instrumentMcpToolHandler(
- getRankTrackerTool.name,
- getRankTrackerTool.config.outputSchema,
- getRankTrackerTool.handler,
- ),
- );
- server.registerTool(
- getRankedKeywordsTool.name,
- getRankedKeywordsTool.config,
- instrumentMcpToolHandler(
- getRankedKeywordsTool.name,
- getRankedKeywordsTool.config.outputSchema,
- getRankedKeywordsTool.handler,
- ),
- );
- server.registerTool(
- findSerpCompetitorsTool.name,
- findSerpCompetitorsTool.config,
- instrumentMcpToolHandler(
- findSerpCompetitorsTool.name,
- findSerpCompetitorsTool.config.outputSchema,
- findSerpCompetitorsTool.handler,
- ),
- );
- server.registerTool(
- searchLocalBusinessesTool.name,
- searchLocalBusinessesTool.config,
- instrumentMcpToolHandler(
- searchLocalBusinessesTool.name,
- searchLocalBusinessesTool.config.outputSchema,
- searchLocalBusinessesTool.handler,
- ),
- );
- server.registerTool(
- getLocalSerpResultsTool.name,
- getLocalSerpResultsTool.config,
- instrumentMcpToolHandler(
- getLocalSerpResultsTool.name,
- getLocalSerpResultsTool.config.outputSchema,
- getLocalSerpResultsTool.handler,
- ),
- );
- server.registerTool(
- getGoogleBusinessQuestionsTool.name,
- getGoogleBusinessQuestionsTool.config,
- instrumentMcpToolHandler(
- getGoogleBusinessQuestionsTool.name,
- getGoogleBusinessQuestionsTool.config.outputSchema,
- getGoogleBusinessQuestionsTool.handler,
- ),
- );
- server.registerTool(
- getKeywordMetricsTool.name,
- getKeywordMetricsTool.config,
- instrumentMcpToolHandler(
- getKeywordMetricsTool.name,
- getKeywordMetricsTool.config.outputSchema,
- getKeywordMetricsTool.handler,
- ),
- );
- server.registerTool(
- getSearchConsolePerformanceTool.name,
- getSearchConsolePerformanceTool.config,
- instrumentMcpToolHandler(
- getSearchConsolePerformanceTool.name,
- getSearchConsolePerformanceTool.config.outputSchema,
- getSearchConsolePerformanceTool.handler,
- ),
- );
- server.registerTool(
- inspectUrlsTool.name,
- inspectUrlsTool.config,
- instrumentMcpToolHandler(
- inspectUrlsTool.name,
- inspectUrlsTool.config.outputSchema,
- inspectUrlsTool.handler,
- ),
- );
- server.registerTool(
- runSiteAuditTool.name,
- runSiteAuditTool.config,
- instrumentMcpToolHandler(
- runSiteAuditTool.name,
- runSiteAuditTool.config.outputSchema,
- runSiteAuditTool.handler,
- ),
- );
- server.registerTool(
- getAuditStatusTool.name,
- getAuditStatusTool.config,
- instrumentMcpToolHandler(
- getAuditStatusTool.name,
- getAuditStatusTool.config.outputSchema,
- getAuditStatusTool.handler,
- ),
- );
- server.registerTool(
- getAuditIssuesTool.name,
- getAuditIssuesTool.config,
- instrumentMcpToolHandler(
- getAuditIssuesTool.name,
- getAuditIssuesTool.config.outputSchema,
- getAuditIssuesTool.handler,
- ),
- );
- server.registerTool(
- getAuditPagesTool.name,
- getAuditPagesTool.config,
- instrumentMcpToolHandler(
- getAuditPagesTool.name,
- getAuditPagesTool.config.outputSchema,
- getAuditPagesTool.handler,
- ),
+ tool.name,
+ tool.config.outputSchema,
+ tool.handler,
+ ) as ToolCallback,
);
}
+
+export function registerOpenSeoMcpTools(server: McpServer) {
+ registerInstrumentedTool(server, whoamiTool);
+ registerInstrumentedTool(server, listProjectsTool);
+ registerInstrumentedTool(server, createProjectTool);
+ registerInstrumentedTool(server, listSavedKeywordsTool);
+ registerInstrumentedTool(server, researchKeywordsTool);
+ registerInstrumentedTool(server, saveKeywordsTool);
+ registerInstrumentedTool(server, getDomainOverviewTool);
+ registerInstrumentedTool(server, getDomainKeywordSuggestionsTool);
+ registerInstrumentedTool(server, getBacklinksOverviewTool);
+ registerInstrumentedTool(server, getBacklinksProfileTool);
+ registerInstrumentedTool(server, getSerpResultsTool);
+ registerInstrumentedTool(server, createRankTrackerTool);
+ registerInstrumentedTool(server, getRankTrackerTool);
+ registerInstrumentedTool(server, addRankTrackingKeywordsTool);
+ registerInstrumentedTool(server, removeRankTrackingKeywordsTool);
+ registerInstrumentedTool(server, estimateRankTrackerCostTool);
+ registerInstrumentedTool(server, runRankTrackerTool);
+ registerInstrumentedTool(server, getRankedKeywordsTool);
+ registerInstrumentedTool(server, findSerpCompetitorsTool);
+ registerInstrumentedTool(server, searchLocalBusinessesTool);
+ registerInstrumentedTool(server, getLocalSerpResultsTool);
+ registerInstrumentedTool(server, getGoogleBusinessQuestionsTool);
+ registerInstrumentedTool(server, getKeywordMetricsTool);
+ registerInstrumentedTool(server, getSearchConsolePerformanceTool);
+ registerInstrumentedTool(server, inspectUrlsTool);
+ registerInstrumentedTool(server, getGoogleAnalyticsOrganicLandingPagesTool);
+ registerInstrumentedTool(server, getGoogleAnalyticsPagePerformanceTool);
+ registerInstrumentedTool(server, getGoogleAnalyticsKeyEventsTool);
+ registerInstrumentedTool(server, getSearchOpportunitiesTool);
+ registerInstrumentedTool(server, getGoogleAnalyticsOrganicOverviewTool);
+ registerInstrumentedTool(server, getGoogleAnalyticsTrafficAcquisitionTool);
+ registerInstrumentedTool(server, getGoogleAnalyticsMeasurementHealthTool);
+ registerInstrumentedTool(server, getGoogleAnalyticsEcommercePerformanceTool);
+ registerInstrumentedTool(server, getGoogleAnalyticsSiteSearchTool);
+ registerInstrumentedTool(server, getGoogleAnalyticsAudienceBreakdownTool);
+ registerInstrumentedTool(server, runSiteAuditTool);
+ registerInstrumentedTool(server, getAuditStatusTool);
+ registerInstrumentedTool(server, getAuditIssuesTool);
+ registerInstrumentedTool(server, getAuditPagesTool);
+}
diff --git a/src/server/mcp/tools/add-rank-tracking-keywords.ts b/src/server/mcp/tools/add-rank-tracking-keywords.ts
new file mode 100644
index 0000000..3bbfbcb
--- /dev/null
+++ b/src/server/mcp/tools/add-rank-tracking-keywords.ts
@@ -0,0 +1,91 @@
+import { z } from "zod";
+import { MAX_TRACKED_KEYWORD_LENGTH } from "@/shared/rank-tracking";
+import { RankTrackingService } from "@/server/features/rank-tracking/services/RankTrackingService";
+import { buildProjectMeta } from "@/server/mcp/context";
+import { mcpResponse } from "@/server/mcp/formatters";
+import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";
+import { withMcpProjectAuth } from "@/server/mcp/project-auth";
+import { projectIdSchema } from "@/server/mcp/schemas";
+
+const inputSchema = {
+ projectId: projectIdSchema,
+ trackerId: z
+ .string()
+ .uuid()
+ .describe("Rank tracker ID from get_rank_tracker."),
+ keywords: z
+ .array(z.string().min(1).max(MAX_TRACKED_KEYWORD_LENGTH))
+ .min(1)
+ .max(2000)
+ .describe("Keywords to track. Existing and repeated keywords are skipped."),
+ maxEstimatedScheduledCheckCredits: z
+ .number()
+ .int()
+ .positive()
+ .optional()
+ .describe(
+ "Nominal queued credits per scheduled check that the user approved after seeing estimate_rank_tracker_cost with additionalKeywordCount. Required for scheduled trackers. This is an estimate approval, not a runtime cap; live fallback may add separately billed credits.",
+ ),
+} as const;
+
+type Args = z.infer>;
+
+export const addRankTrackingKeywordsTool = {
+ name: "add_rank_tracking_keywords",
+ config: {
+ title: "Add rank tracking keywords",
+ description:
+ "Add keywords to an existing rank tracker. The mutation itself uses no credits and does not start a check or fetch metrics, but scheduled trackers will spend credits on future recurring checks. For a scheduled tracker, call estimate_rank_tracker_cost with additionalKeywordCount, show the recurring estimate and live-fallback caveat to the user, and pass the approved nominal per-check estimate as maxEstimatedScheduledCheckCredits. This approval is not a runtime spending cap: rejected, failed, or timed-out queued tasks may use additional separately billed live fallback. Existing and repeated keywords are skipped, and `added` is the number actually inserted.",
+ inputSchema,
+ outputSchema: z
+ .object({
+ trackerId: z.string(),
+ requested: z.number(),
+ added: z.number(),
+ addedIds: z.array(z.string()),
+ scheduledEstimate: z
+ .object({
+ scheduleInterval: z.enum(["daily", "weekly", "monthly"]),
+ costUsd: z.number(),
+ costCredits: z.number(),
+ checksPerMonth: z.number(),
+ monthlyCostUsd: z.number(),
+ monthlyCostCredits: z.number(),
+ })
+ .optional(),
+ ...optionalMetaOutputSchema,
+ })
+ .passthrough(),
+ annotations: {
+ readOnlyHint: false,
+ openWorldHint: false,
+ destructiveHint: false,
+ },
+ },
+ handler: withMcpProjectAuth(async (args: Args, context) => {
+ const result = await RankTrackingService.addKeywords(
+ args.trackerId,
+ args.projectId,
+ args.keywords,
+ {
+ kind: "credit_ceiling",
+ maxEstimatedScheduledCheckCredits:
+ args.maxEstimatedScheduledCheckCredits,
+ },
+ );
+ const requested = args.keywords.length;
+ return mcpResponse({
+ text: `Added ${result.added} of ${requested} requested keyword${requested === 1 ? "" : "s"} to tracker ${args.trackerId}. No check was started and no credits were used.${result.scheduledEstimate ? ` Future ${result.scheduledEstimate.scheduleInterval} checks have a nominal estimate of ${result.scheduledEstimate.costCredits} credits each (~${result.scheduledEstimate.monthlyCostCredits} credits/month); live fallback may add separately billed credits.` : ""}`,
+ meta: buildProjectMeta(
+ context,
+ args.projectId,
+ `/p/${args.projectId}/rank-tracking/${args.trackerId}`,
+ ),
+ structuredContent: {
+ trackerId: args.trackerId,
+ requested,
+ ...result,
+ },
+ });
+ }),
+};
diff --git a/src/server/mcp/tools/create-project.test.ts b/src/server/mcp/tools/create-project.test.ts
index 863f741..46bbf01 100644
--- a/src/server/mcp/tools/create-project.test.ts
+++ b/src/server/mcp/tools/create-project.test.ts
@@ -1,7 +1,6 @@
-import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
-import type { ToolExtra } from "@/server/mcp/context";
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
+import { createProjectTool } from "./create-project";
+import { makeToolExtra } from "./tool-test-support";
const mocks = vi.hoisted(() => ({
createProject: vi.fn(),
@@ -13,36 +12,10 @@ vi.mock("@/server/features/projects/services/ProjectService", () => ({
},
}));
-const authContext = {
- userId: "user_123",
- userEmail: "alice@example.com",
- organizationId: "org_123",
- clientId: "client_123",
- scopes: ["mcp"],
- audience: "https://open-seo.test/mcp",
- subject: "user_123",
- baseUrl: "https://open-seo.test",
-};
-
-const toolExtra: ToolExtra = {
- signal: new AbortController().signal,
- requestId: 1,
- sendNotification: vi.fn(),
- sendRequest: vi.fn(),
- authInfo: {
- token: "token",
- clientId: "client_123",
- scopes: ["mcp"],
- resource: new URL("https://open-seo.test/mcp"),
- extra: { [MCP_AUTH_CONTEXT_PROP]: authContext },
- } satisfies AuthInfo,
-};
+const toolExtra = makeToolExtra();
describe("create_project MCP tool", () => {
- beforeEach(() => {
- vi.resetModules();
- mocks.createProject.mockReset();
- });
+ beforeEach(() => {});
it("creates a project scoped to the caller's organization and returns it", async () => {
mocks.createProject.mockResolvedValue({
@@ -52,7 +25,6 @@ describe("create_project MCP tool", () => {
locationCode: 2840,
languageCode: "en",
});
- const { createProjectTool } = await import("./create-project");
const result = await createProjectTool.handler(
{ name: "Acme", domain: "acme.com", locationCode: 2840 },
@@ -89,7 +61,6 @@ describe("create_project MCP tool", () => {
locationCode: 2840,
languageCode: "en",
});
- const { createProjectTool } = await import("./create-project");
await createProjectTool.handler({ name: "Just a name" }, toolExtra);
@@ -99,8 +70,6 @@ describe("create_project MCP tool", () => {
});
it("rejects a languageCode without a locationCode (market pair rule)", async () => {
- const { createProjectTool } = await import("./create-project");
-
await expect(
createProjectTool.handler(
{ name: "Bad market", languageCode: "en" },
diff --git a/src/server/mcp/tools/create-rank-tracker.ts b/src/server/mcp/tools/create-rank-tracker.ts
new file mode 100644
index 0000000..cc21766
--- /dev/null
+++ b/src/server/mcp/tools/create-rank-tracker.ts
@@ -0,0 +1,126 @@
+import { waitUntil } from "cloudflare:workers";
+import { z } from "zod";
+import { RankTrackingService } from "@/server/features/rank-tracking/services/RankTrackingService";
+import { AppError } from "@/server/lib/errors";
+import { captureServerEvent } from "@/server/lib/posthog";
+import { buildProjectMeta } from "@/server/mcp/context";
+import { mcpResponse } from "@/server/mcp/formatters";
+import {
+ looseObjectOutputSchema,
+ optionalMetaOutputSchema,
+} from "@/server/mcp/output-schemas";
+import { withMcpProjectAuth } from "@/server/mcp/project-auth";
+import {
+ languageCodeSchema,
+ locationCodeSchema,
+ projectIdSchema,
+} from "@/server/mcp/schemas";
+import { domainField } from "@/types/schemas/domain";
+
+const inputSchema = {
+ projectId: projectIdSchema,
+ domain: domainField
+ .optional()
+ .describe(
+ "Domain to track. Defaults to the project's domain. Accepts a hostname or URL and stores the normalized hostname.",
+ ),
+ locationCode: locationCodeSchema.optional(),
+ languageCode: languageCodeSchema.optional(),
+ locationName: z
+ .string()
+ .trim()
+ .min(1)
+ .max(200)
+ .optional()
+ .describe("Optional city or region name for local rank tracking."),
+ devices: z
+ .enum(["desktop", "mobile", "both"])
+ .optional()
+ .describe("Devices to track. Defaults to mobile."),
+ serpDepth: z
+ .number()
+ .int()
+ .min(10)
+ .max(100)
+ .multipleOf(10)
+ .optional()
+ .describe("Number of Google results to inspect. Defaults to 40."),
+ scheduleInterval: z
+ .enum(["manual", "daily", "weekly", "monthly"])
+ .optional()
+ .describe(
+ "Check schedule. Defaults to manual so creating a tracker cannot cause future credit spend. Scheduled checks may use credits later.",
+ ),
+} as const;
+
+type Args = z.infer>;
+
+export const createRankTrackerTool = {
+ name: "create_rank_tracker",
+ config: {
+ title: "Create rank tracker",
+ description:
+ "Create a rank tracking configuration for a project. Creating an empty tracker uses no credits and starts no check, but daily, weekly, and monthly trackers will spend credits after keywords are added. The domain defaults to the project's domain; market defaults to the project's market; devices default to mobile, search depth to 40, and schedule to manual. Use estimate_rank_tracker_cost before adding keywords to a scheduled tracker or starting a live run. Call get_rank_tracker first to avoid duplicates.",
+ inputSchema,
+ outputSchema: z
+ .object({
+ trackerId: z.string(),
+ config: looseObjectOutputSchema,
+ ...optionalMetaOutputSchema,
+ })
+ .passthrough(),
+ annotations: {
+ readOnlyHint: false,
+ openWorldHint: false,
+ destructiveHint: false,
+ },
+ },
+ handler: withMcpProjectAuth(async (args: Args, context) => {
+ const domain = args.domain ?? context.project.domain;
+ if (!domain) {
+ throw new AppError(
+ "VALIDATION_ERROR",
+ "Provide a domain or set the project's domain first",
+ );
+ }
+
+ const config = await RankTrackingService.createConfig({
+ projectId: args.projectId,
+ projectMarket: context.project,
+ domain,
+ locationCode: args.locationCode,
+ languageCode: args.languageCode,
+ locationName: args.locationName,
+ devices: args.devices ?? "mobile",
+ serpDepth: args.serpDepth ?? 40,
+ scheduleInterval: args.scheduleInterval ?? "manual",
+ });
+ waitUntil(
+ captureServerEvent({
+ distinctId: context.auth.userId,
+ event: "rank_tracking:config_create",
+ organizationId: context.auth.organizationId,
+ properties: {
+ project_id: args.projectId,
+ domain: config.domain,
+ devices: config.devices,
+ schedule: config.scheduleInterval,
+ source: "mcp",
+ },
+ }),
+ );
+
+ return mcpResponse({
+ text: `Created rank tracker ${config.id} for ${config.domain} (${config.devices}, top ${config.serpDepth}, ${config.scheduleInterval}). No keywords were added, no check was started, and no credits were used.${config.scheduleInterval === "manual" ? "" : " Scheduled checks will spend credits after keywords are added; estimate and obtain approval before adding them."}`,
+ meta: buildProjectMeta(
+ context,
+ args.projectId,
+ `/p/${args.projectId}/rank-tracking/${config.id}`,
+ ),
+ structuredContent: {
+ trackerId: config.id,
+ config,
+ },
+ });
+ }),
+};
diff --git a/src/server/mcp/tools/dataforseo-research-tools.google-ads.test.ts b/src/server/mcp/tools/dataforseo-research-tools.google-ads.test.ts
index 18faf67..9639888 100644
--- a/src/server/mcp/tools/dataforseo-research-tools.google-ads.test.ts
+++ b/src/server/mcp/tools/dataforseo-research-tools.google-ads.test.ts
@@ -1,9 +1,8 @@
-import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
-import type { ToolExtra } from "@/server/mcp/context";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { z } from "zod";
-import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
import type { fetchKeywordMetricsForList as FetchKeywordMetricsForList } from "@/server/lib/dataforseo/keyword-metrics";
+import { getKeywordMetricsTool } from "./dataforseo-research-tools";
+import { makeToolExtra } from "./tool-test-support";
const mocks = vi.hoisted(() => ({
createDataforseoClient: vi.fn(),
@@ -32,36 +31,10 @@ vi.mock("@/server/features/projects/services/ProjectService", () => ({
},
}));
-const authContext = {
- userId: "user_123",
- userEmail: "alice@example.com",
- organizationId: "org_123",
- clientId: "client_123",
- scopes: ["mcp"],
- audience: "https://open-seo.test/mcp",
- subject: "user_123",
- baseUrl: "https://open-seo.test",
-};
-
-const toolExtra: ToolExtra = {
- signal: new AbortController().signal,
- requestId: 1,
- sendNotification: vi.fn(),
- sendRequest: vi.fn(),
- authInfo: {
- token: "token",
- clientId: "client_123",
- scopes: ["mcp"],
- resource: new URL("https://open-seo.test/mcp"),
- extra: { [MCP_AUTH_CONTEXT_PROP]: authContext },
- } satisfies AuthInfo,
-};
+const toolExtra = makeToolExtra();
describe("get_keyword_metrics for Google-Ads-only locations", () => {
beforeEach(() => {
- vi.resetModules();
- mocks.createDataforseoClient.mockReset();
- mocks.getProjectForOrganization.mockReset();
mocks.getProjectForOrganization.mockResolvedValue({
id: "project_1",
locationCode: 2840,
@@ -86,8 +59,6 @@ describe("get_keyword_metrics for Google-Ads-only locations", () => {
labs: { keywordOverview },
keywords: { adsSearchVolume },
});
- const { getKeywordMetricsTool } =
- await import("./dataforseo-research-tools");
const result = await getKeywordMetricsTool.handler(
{
@@ -142,8 +113,6 @@ describe("get_keyword_metrics for Google-Ads-only locations", () => {
mocks.createDataforseoClient.mockReturnValue({
labs: { keywordOverview },
});
- const { getKeywordMetricsTool } =
- await import("./dataforseo-research-tools");
const result = await getKeywordMetricsTool.handler(
{
diff --git a/src/server/mcp/tools/dataforseo-research-tools.market.test.ts b/src/server/mcp/tools/dataforseo-research-tools.market.test.ts
index 73538df..c29e982 100644
--- a/src/server/mcp/tools/dataforseo-research-tools.market.test.ts
+++ b/src/server/mcp/tools/dataforseo-research-tools.market.test.ts
@@ -1,7 +1,9 @@
-import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
-import type { ToolExtra } from "@/server/mcp/context";
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
+import {
+ findSerpCompetitorsTool,
+ getRankedKeywordsTool,
+} from "./dataforseo-research-tools";
+import { makeToolExtra } from "./tool-test-support";
// Market resolution for get_ranked_keywords: the explicit country selector and
// the project's default-market fallback (projects.locationCode/languageCode).
@@ -25,30 +27,7 @@ vi.mock("@/server/features/projects/services/ProjectService", () => ({
},
}));
-const authContext = {
- userId: "user_123",
- userEmail: "alice@example.com",
- organizationId: "org_123",
- clientId: "client_123",
- scopes: ["mcp"],
- audience: "https://open-seo.test/mcp",
- subject: "user_123",
- baseUrl: "https://open-seo.test",
-};
-
-const toolExtra: ToolExtra = {
- signal: new AbortController().signal,
- requestId: 1,
- sendNotification: vi.fn(),
- sendRequest: vi.fn(),
- authInfo: {
- token: "token",
- clientId: "client_123",
- scopes: ["mcp"],
- resource: new URL("https://open-seo.test/mcp"),
- extra: { [MCP_AUTH_CONTEXT_PROP]: authContext },
- } satisfies AuthInfo,
-};
+const toolExtra = makeToolExtra();
function setProject(market: { locationCode: number; languageCode: string }) {
mocks.getProjectForOrganization.mockResolvedValue({
@@ -74,7 +53,6 @@ async function runRankedKeywords(args: MarketArgs) {
mocks.createDataforseoClient.mockReturnValue({
domain: { rankedKeywords },
});
- const { getRankedKeywordsTool } = await import("./dataforseo-research-tools");
await getRankedKeywordsTool.handler(
{ projectId: "project_1", target: "acmeexample.com", ...args },
toolExtra,
@@ -87,8 +65,6 @@ async function runSerpCompetitors(args: MarketArgs) {
mocks.createDataforseoClient.mockReturnValue({
labs: { serpCompetitors },
});
- const { findSerpCompetitorsTool } =
- await import("./dataforseo-research-tools");
await findSerpCompetitorsTool.handler(
{ projectId: "project_1", keywords: ["seo"], ...args },
toolExtra,
@@ -98,9 +74,6 @@ async function runSerpCompetitors(args: MarketArgs) {
describe("market resolution for Labs tools", () => {
beforeEach(() => {
- vi.resetModules();
- mocks.createDataforseoClient.mockReset();
- mocks.getProjectForOrganization.mockReset();
setProject({ locationCode: 2840, languageCode: "en" });
});
@@ -115,9 +88,6 @@ describe("market resolution for Labs tools", () => {
});
it("exposes explicit location and language selectors on both tool schemas", async () => {
- const { findSerpCompetitorsTool, getRankedKeywordsTool } =
- await import("./dataforseo-research-tools");
-
expect(getRankedKeywordsTool.config.inputSchema.locationCode).toBeDefined();
expect(getRankedKeywordsTool.config.inputSchema.languageCode).toBeDefined();
expect(
diff --git a/src/server/mcp/tools/dataforseo-research-tools.test.ts b/src/server/mcp/tools/dataforseo-research-tools.test.ts
index 059a96b..387cd87 100644
--- a/src/server/mcp/tools/dataforseo-research-tools.test.ts
+++ b/src/server/mcp/tools/dataforseo-research-tools.test.ts
@@ -1,9 +1,8 @@
-import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
-import type { ToolExtra } from "@/server/mcp/context";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { z } from "zod";
-import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
import type { fetchKeywordMetricsForList as FetchKeywordMetricsForList } from "@/server/lib/dataforseo/keyword-metrics";
+import * as researchTools from "./dataforseo-research-tools";
+import { makeToolExtra, textContent } from "./tool-test-support";
const mocks = vi.hoisted(() => ({
createDataforseoClient: vi.fn(),
@@ -32,37 +31,7 @@ vi.mock("@/server/features/projects/services/ProjectService", () => ({
},
}));
-const authContext = {
- userId: "user_123",
- userEmail: "alice@example.com",
- organizationId: "org_123",
- clientId: "client_123",
- scopes: ["mcp"],
- audience: "https://open-seo.test/mcp",
- subject: "user_123",
- baseUrl: "https://open-seo.test",
-};
-
-const toolExtra: ToolExtra = {
- signal: new AbortController().signal,
- requestId: 1,
- sendNotification: vi.fn(),
- sendRequest: vi.fn(),
- authInfo: {
- token: "token",
- clientId: "client_123",
- scopes: ["mcp"],
- resource: new URL("https://open-seo.test/mcp"),
- extra: { [MCP_AUTH_CONTEXT_PROP]: authContext },
- } satisfies AuthInfo,
-};
-
-function textOf(result: {
- content?: Array<{ type: string; text?: string }>;
-}): string {
- const first = result.content?.[0];
- return first?.type === "text" ? (first.text ?? "") : "";
-}
+const toolExtra = makeToolExtra();
const usProjectRow = {
id: "project_1",
@@ -72,9 +41,6 @@ const usProjectRow = {
describe("DataForSEO research MCP tools", () => {
beforeEach(() => {
- vi.resetModules();
- mocks.createDataforseoClient.mockReset();
- mocks.getProjectForOrganization.mockReset();
mocks.getProjectForOrganization.mockResolvedValue(usProjectRow);
});
@@ -91,8 +57,7 @@ describe("DataForSEO research MCP tools", () => {
business: { businessListings, questionsAnswers },
serp: { local },
});
- const { searchLocalBusinessesTool } =
- await import("./dataforseo-research-tools");
+ const { searchLocalBusinessesTool } = researchTools;
const result = await searchLocalBusinessesTool.handler(
{
@@ -122,8 +87,8 @@ describe("DataForSEO research MCP tools", () => {
.passthrough()
.parse(result.structuredContent);
expect(content.businesses).toEqual([{ title: "Acme Cafe" }]);
- expect(textOf(result)).toContain("title | category");
- expect(textOf(result)).toContain("Acme Cafe");
+ expect(textContent(result)).toContain("title | category");
+ expect(textContent(result)).toContain("Acme Cafe");
});
it("fetches one local SERP with search_places disabled", async () => {
@@ -139,8 +104,7 @@ describe("DataForSEO research MCP tools", () => {
mocks.createDataforseoClient.mockReturnValue({
serp: { local },
});
- const { getLocalSerpResultsTool } =
- await import("./dataforseo-research-tools");
+ const { getLocalSerpResultsTool } = researchTools;
const result = await getLocalSerpResultsTool.handler(
{
@@ -176,8 +140,8 @@ describe("DataForSEO research MCP tools", () => {
rank_group: 1,
rank_absolute: 2,
});
- expect(textOf(result)).toContain("rank | title | rating");
- expect(textOf(result)).toContain("Acme Cafe");
+ expect(textContent(result)).toContain("rank | title | rating");
+ expect(textContent(result)).toContain("Acme Cafe");
});
it("fetches Google Business Q&A as an explicit tool", async () => {
@@ -188,8 +152,7 @@ describe("DataForSEO research MCP tools", () => {
mocks.createDataforseoClient.mockReturnValue({
business: { questionsAnswers },
});
- const { getGoogleBusinessQuestionsTool } =
- await import("./dataforseo-research-tools");
+ const { getGoogleBusinessQuestionsTool } = researchTools;
const result = await getGoogleBusinessQuestionsTool.handler(
{
@@ -217,8 +180,8 @@ describe("DataForSEO research MCP tools", () => {
expect(content.questions).toEqual([
{ question_text: "Do you serve breakfast?" },
]);
- expect(textOf(result)).toContain("question | asked by");
- expect(textOf(result)).toContain("Do you serve breakfast?");
+ expect(textContent(result)).toContain("question | asked by");
+ expect(textContent(result)).toContain("Do you serve breakfast?");
});
it("passes only explicit brand exclusions to ranked keyword filters", async () => {
@@ -230,8 +193,7 @@ describe("DataForSEO research MCP tools", () => {
mocks.createDataforseoClient.mockReturnValue({
domain: { rankedKeywords },
});
- const { getRankedKeywordsTool } =
- await import("./dataforseo-research-tools");
+ const { getRankedKeywordsTool } = researchTools;
await getRankedKeywordsTool.handler(
{
@@ -258,8 +220,7 @@ describe("DataForSEO research MCP tools", () => {
mocks.createDataforseoClient.mockReturnValue({
labs: { serpCompetitors },
});
- const { findSerpCompetitorsTool } =
- await import("./dataforseo-research-tools");
+ const { findSerpCompetitorsTool } = researchTools;
const result = await findSerpCompetitorsTool.handler(
{
@@ -277,13 +238,12 @@ describe("DataForSEO research MCP tools", () => {
expect(content.competitors.map((row) => row.domain)).toEqual([
"competitor.example",
]);
- expect(textOf(result)).toContain("domain | keywords | avg pos");
- expect(textOf(result)).toContain("competitor.example");
+ expect(textContent(result)).toContain("domain | keywords | avg pos");
+ expect(textContent(result)).toContain("competitor.example");
});
it("keeps AI overview result types out of SERP competitors", async () => {
- const { findSerpCompetitorsTool, getRankedKeywordsTool } =
- await import("./dataforseo-research-tools");
+ const { findSerpCompetitorsTool, getRankedKeywordsTool } = researchTools;
expect(
getRankedKeywordsTool.config.inputSchema.resultTypes.safeParse([
@@ -321,8 +281,7 @@ describe("DataForSEO research MCP tools", () => {
mocks.createDataforseoClient.mockReturnValue({
labs: { keywordOverview },
});
- const { getKeywordMetricsTool } =
- await import("./dataforseo-research-tools");
+ const { getKeywordMetricsTool } = researchTools;
const result = await getKeywordMetricsTool.handler(
{ projectId: "project_1", keywords: ["seo automation"] },
@@ -358,7 +317,7 @@ describe("DataForSEO research MCP tools", () => {
keyword_difficulty: 18,
main_intent: "commercial",
});
- const out = textOf(result);
+ const out = textContent(result);
expect(out).toContain("keyword | volume | KD | CPC | competition | intent");
expect(out).toContain("seo automation");
});
@@ -373,8 +332,7 @@ describe("DataForSEO research MCP tools", () => {
mocks.createDataforseoClient.mockReturnValue({
labs: { keywordOverview },
});
- const { getKeywordMetricsTool } =
- await import("./dataforseo-research-tools");
+ const { getKeywordMetricsTool } = researchTools;
const result = await getKeywordMetricsTool.handler(
{
@@ -406,8 +364,7 @@ describe("DataForSEO research MCP tools", () => {
mocks.createDataforseoClient.mockReturnValue({
labs: { keywordOverview },
});
- const { getKeywordMetricsTool } =
- await import("./dataforseo-research-tools");
+ const { getKeywordMetricsTool } = researchTools;
const result = await getKeywordMetricsTool.handler(
{
diff --git a/src/server/mcp/tools/estimate-rank-tracker-cost.ts b/src/server/mcp/tools/estimate-rank-tracker-cost.ts
new file mode 100644
index 0000000..45797fc
--- /dev/null
+++ b/src/server/mcp/tools/estimate-rank-tracker-cost.ts
@@ -0,0 +1,81 @@
+import { z } from "zod";
+import { RankTrackingService } from "@/server/features/rank-tracking/services/RankTrackingService";
+import { buildProjectMeta } from "@/server/mcp/context";
+import { mcpResponse } from "@/server/mcp/formatters";
+import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";
+import { withMcpProjectAuth } from "@/server/mcp/project-auth";
+import { projectIdSchema } from "@/server/mcp/schemas";
+
+const inputSchema = {
+ projectId: projectIdSchema,
+ trackerId: z
+ .string()
+ .uuid()
+ .describe("Rank tracker ID from get_rank_tracker."),
+ additionalKeywordCount: z
+ .number()
+ .int()
+ .min(0)
+ .max(1000)
+ .optional()
+ .describe(
+ "Number of keywords you plan to add. Include this before adding to a scheduled tracker so the response projects its recurring per-check and monthly cost.",
+ ),
+} as const;
+
+type Args = z.infer>;
+
+export const estimateRankTrackerCostTool = {
+ name: "estimate_rank_tracker_cost",
+ config: {
+ title: "Estimate rank tracker cost",
+ description:
+ "Estimate rank tracker cost without spending credits or starting a check. The live estimate covers one explicit run_rank_tracker check. For a scheduled tracker, the response also includes nominal queued per-check and approximate monthly recurring cost. Pass additionalKeywordCount before adding keywords to project the post-add cost. Scheduled estimates are not runtime caps; rejected, failed, or timed-out queued tasks may use additional separately billed live fallback.",
+ inputSchema,
+ outputSchema: z
+ .object({
+ trackerId: z.string(),
+ costUsd: z.number(),
+ costCredits: z.number(),
+ keywordCount: z.number(),
+ devicesCount: z.number(),
+ totalChecks: z.number(),
+ method: z.literal("live"),
+ existingKeywordCount: z.number(),
+ additionalKeywordCount: z.number(),
+ scheduledEstimate: z
+ .object({
+ scheduleInterval: z.enum(["daily", "weekly", "monthly"]),
+ costUsd: z.number(),
+ costCredits: z.number(),
+ checksPerMonth: z.number(),
+ monthlyCostUsd: z.number(),
+ monthlyCostCredits: z.number(),
+ })
+ .optional(),
+ ...optionalMetaOutputSchema,
+ })
+ .passthrough(),
+ annotations: {
+ readOnlyHint: true,
+ openWorldHint: false,
+ destructiveHint: false,
+ },
+ },
+ handler: withMcpProjectAuth(async (args: Args, context) => {
+ const estimate = await RankTrackingService.estimateCost(
+ args.trackerId,
+ args.projectId,
+ args.additionalKeywordCount,
+ );
+ return mcpResponse({
+ text: `One live check for tracker ${args.trackerId} is estimated at $${estimate.costUsd.toFixed(4)} (${estimate.costCredits} credits): ${estimate.keywordCount} keyword${estimate.keywordCount === 1 ? "" : "s"} × ${estimate.devicesCount} device${estimate.devicesCount === 1 ? "" : "s"} = ${estimate.totalChecks} SERP checks.${estimate.additionalKeywordCount > 0 ? ` This projects ${estimate.additionalKeywordCount} additional keyword${estimate.additionalKeywordCount === 1 ? "" : "s"}.` : ""}${estimate.scheduledEstimate ? ` Its ${estimate.scheduledEstimate.scheduleInterval} queued checks have a nominal estimate of $${estimate.scheduledEstimate.costUsd.toFixed(4)} (${estimate.scheduledEstimate.costCredits} credits) each, or about $${estimate.scheduledEstimate.monthlyCostUsd.toFixed(4)} (${estimate.scheduledEstimate.monthlyCostCredits} credits) per month. Show the user that rejected, failed, or timed-out queued tasks may use additional separately billed live fallback, then use the per-check estimate as maxEstimatedScheduledCheckCredits when adding keywords.` : ""} No check was started.`,
+ meta: buildProjectMeta(
+ context,
+ args.projectId,
+ `/p/${args.projectId}/rank-tracking/${args.trackerId}`,
+ ),
+ structuredContent: { trackerId: args.trackerId, ...estimate },
+ });
+ }),
+};
diff --git a/src/server/mcp/tools/get-rank-tracker.ts b/src/server/mcp/tools/get-rank-tracker.ts
index 044a2c0..d77bd68 100644
--- a/src/server/mcp/tools/get-rank-tracker.ts
+++ b/src/server/mcp/tools/get-rank-tracker.ts
@@ -1,6 +1,5 @@
import { z } from "zod";
-import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
-import { getLatestResults } from "@/server/features/rank-tracking/services/rankTrackingResults";
+import { RankTrackingService } from "@/server/features/rank-tracking/services/RankTrackingService";
import { mcpResponse } from "@/server/mcp/formatters";
import { buildProjectMeta } from "@/server/mcp/context";
import {
@@ -33,6 +32,7 @@ const inputSchema = {
projectId: projectIdSchema,
trackerId: z
.string()
+ .uuid()
.optional()
.describe(
"Rank tracker config ID. If omitted, lists all rank trackers in the project.",
@@ -46,13 +46,26 @@ export const getRankTrackerTool = {
config: {
title: "Get rank tracker",
description:
- "Read-only access to rank tracker configs and their latest results. With `trackerId`, returns config + latest snapshot per keyword. Without it, lists all trackers in the project. Uses no credits — reads from OpenSEO state, no DataForSEO call. To trigger a new check, use the dashboard.",
+ "Read-only access to rank tracker configs and their latest results. With `trackerId`, returns config + latest snapshot per keyword, including `trackingKeywordId` for removals. Without it, lists all trackers in the project. Uses no credits. Use create_rank_tracker when no tracker exists; then use add_rank_tracking_keywords, remove_rank_tracking_keywords, estimate_rank_tracker_cost, or run_rank_tracker to manage it. `lastCheckedAt` shows position freshness.",
inputSchema,
outputSchema: z
.object({
configs: z.array(looseObjectOutputSchema).optional(),
config: looseObjectOutputSchema.optional(),
- results: looseObjectOutputSchema.optional(),
+ results: z
+ .object({
+ rows: z.array(looseObjectOutputSchema),
+ run: z
+ .object({
+ id: z.string(),
+ lastCheckedAt: z.string().nullable(),
+ status: z.enum(["pending", "running", "completed", "failed"]),
+ errorMessage: z.string().nullable(),
+ })
+ .nullable(),
+ })
+ .passthrough()
+ .optional(),
...optionalMetaOutputSchema,
})
.passthrough(),
@@ -64,9 +77,7 @@ export const getRankTrackerTool = {
},
handler: withMcpProjectAuth(async (args: Args, context) => {
if (!args.trackerId) {
- const configs = await RankTrackingRepository.getConfigsForProject(
- args.projectId,
- );
+ const configs = await RankTrackingService.getConfigs(args.projectId);
const text =
configs.length === 0
? "No rank trackers configured for this project."
@@ -88,26 +99,24 @@ export const getRankTrackerTool = {
});
}
- const config = await RankTrackingRepository.getConfigById({
- configId: args.trackerId,
- projectId: args.projectId,
- });
- if (!config) {
- return mcpResponse({
- text: `Rank tracker ${args.trackerId} not found in project ${args.projectId}.`,
- meta: buildProjectMeta(context, args.projectId),
- });
- }
- const results = await getLatestResults(args.trackerId, args.projectId);
+ const { config, results } = await RankTrackingService.getTracker(
+ args.trackerId,
+ args.projectId,
+ );
const text = [
`Tracker ${config.id} (${config.domain}):`,
`Schedule: ${config.scheduleInterval}, devices: ${config.devices}, depth: ${config.serpDepth}`,
`Latest run: ${results.run?.lastCheckedAt ?? "never"}`,
+ results.run?.status === "failed"
+ ? `Latest run failed: ${results.run.errorMessage ?? "Unknown error"}`
+ : null,
`Keywords (${results.rows.length}):`,
results.rows.length === 0
? "No keywords tracked yet."
: formatMcpTable(results.rows, RANK_RESULT_COLUMNS),
- ].join("\n");
+ ]
+ .filter((line): line is string => line !== null)
+ .join("\n");
return mcpResponse({
text,
meta: buildProjectMeta(
diff --git a/src/server/mcp/tools/google-analytics-tools.test.ts b/src/server/mcp/tools/google-analytics-tools.test.ts
new file mode 100644
index 0000000..fd0ee8b
--- /dev/null
+++ b/src/server/mcp/tools/google-analytics-tools.test.ts
@@ -0,0 +1,312 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { makeGa4ReportResult } from "@/server/features/ga4/services/ga4-test-fixtures";
+import { Ga4ReportError } from "@/server/lib/ga4Errors";
+import * as tools from "./google-analytics-tools";
+import { makeToolExtra } from "./tool-test-support";
+
+const mocks = vi.hoisted(() => ({
+ runReport: vi.fn(),
+ getOrganicOverview: vi.fn(),
+ getMeasurementHealth: vi.fn(),
+ getOpportunities: vi.fn(),
+ getProjectForOrganization: vi.fn(),
+}));
+
+vi.mock("cloudflare:workers", () => ({ env: {} }));
+vi.mock("@/server/features/ga4/services/Ga4ReportingService", () => ({
+ Ga4ReportingService: {
+ runReport: mocks.runReport,
+ },
+}));
+vi.mock("@/server/features/ga4/services/Ga4OrganicOverviewService", () => ({
+ Ga4OrganicOverviewService: {
+ getOrganicOverview: mocks.getOrganicOverview,
+ },
+}));
+vi.mock("@/server/features/ga4/services/Ga4MeasurementHealthService", () => ({
+ Ga4MeasurementHealthService: {
+ getMeasurementHealth: mocks.getMeasurementHealth,
+ },
+}));
+vi.mock("@/server/features/ga4/services/SearchOpportunityService", () => ({
+ SearchOpportunityService: { getOpportunities: mocks.getOpportunities },
+}));
+vi.mock("@/server/features/projects/services/ProjectService", () => ({
+ ProjectService: {
+ getProjectForOrganization: mocks.getProjectForOrganization,
+ },
+}));
+
+const toolExtra = makeToolExtra();
+const reportResult = makeGa4ReportResult({
+ rowCount: 1,
+ totalRowCount: 1,
+ rows: [{ hostName: "example.com", sessions: 5 }],
+});
+
+describe("Google Analytics MCP tools", () => {
+ beforeEach(() => {
+ mocks.runReport.mockResolvedValue(reportResult);
+ mocks.getProjectForOrganization.mockResolvedValue({ id: "project_1" });
+ });
+
+ it("registers strict public input schemas", async () => {
+ const { getGoogleAnalyticsOrganicLandingPagesTool } = tools;
+ expect(
+ getGoogleAnalyticsOrganicLandingPagesTool.config.inputSchema.safeParse({
+ projectId: "project_1",
+ unknown: true,
+ }).success,
+ ).toBe(false);
+ expect(
+ getGoogleAnalyticsOrganicLandingPagesTool.config.inputSchema.safeParse({
+ projectId: "project_1",
+ startDate: "2026-01-01",
+ }).success,
+ ).toBe(true);
+ });
+
+ it("returns normalized landing-page report content", async () => {
+ const { getGoogleAnalyticsOrganicLandingPagesTool } = tools;
+ const result = await getGoogleAnalyticsOrganicLandingPagesTool.handler(
+ { projectId: "project_1", limit: 10, offset: 0 },
+ toolExtra,
+ );
+ expect(mocks.runReport).toHaveBeenCalledWith({
+ projectId: "project_1",
+ limit: 10,
+ offset: 0,
+ kind: "landing_pages",
+ channel: "organic_search",
+ });
+ expect(result.structuredContent).toMatchObject({
+ status: "ok",
+ rowCount: 1,
+ meta: { projectId: "project_1", organizationId: "org_123" },
+ });
+ });
+
+ it("maps page-performance and key-event options to fixed reports", async () => {
+ const {
+ getGoogleAnalyticsKeyEventsTool,
+ getGoogleAnalyticsPagePerformanceTool,
+ } = tools;
+ await getGoogleAnalyticsPagePerformanceTool.handler(
+ {
+ projectId: "project_1",
+ includeDate: true,
+ channel: "all",
+ limit: 100,
+ offset: 0,
+ },
+ toolExtra,
+ );
+ await getGoogleAnalyticsKeyEventsTool.handler(
+ {
+ projectId: "project_1",
+ breakdown: "event_and_landing_page",
+ channel: "organic_search",
+ comparePreviousPeriod: false,
+ limit: 100,
+ offset: 0,
+ },
+ toolExtra,
+ );
+ expect(mocks.runReport).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
+ kind: "page_performance",
+ includeDate: true,
+ channel: "all",
+ }),
+ );
+ expect(mocks.runReport).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ kind: "key_events",
+ breakdown: "event_and_landing_page",
+ }),
+ );
+ });
+
+ it("returns stable connection errors without leaking upstream details", async () => {
+ mocks.runReport.mockRejectedValue(
+ new Ga4ReportError(
+ "ga4_reconnect_required",
+ "The Google Analytics connection has expired or was revoked.",
+ ),
+ );
+ const { getGoogleAnalyticsKeyEventsTool } = tools;
+ const result = await getGoogleAnalyticsKeyEventsTool.handler(
+ {
+ projectId: "project_1",
+ breakdown: "event",
+ channel: "organic_search",
+ comparePreviousPeriod: false,
+ limit: 100,
+ offset: 0,
+ },
+ toolExtra,
+ );
+ expect(result.structuredContent).toMatchObject({
+ status: "error",
+ error: {
+ code: "ga4_reconnect_required",
+ actionUrl: "https://open-seo.test/p/project_1/settings",
+ },
+ });
+ });
+
+ it("returns the cross-source opportunity envelope", async () => {
+ mocks.getOpportunities.mockResolvedValue({
+ status: "ok",
+ rowCount: 1,
+ totalCandidateRows: 2,
+ rows: [{ page: "https://example.com/a", score: 90 }],
+ coverage: { matchedRows: 1 },
+ });
+ const { getSearchOpportunitiesTool } = tools;
+ const result = await getSearchOpportunitiesTool.handler(
+ { projectId: "project_1", limit: 25 },
+ toolExtra,
+ );
+ expect(mocks.getOpportunities).toHaveBeenCalledWith({
+ projectId: "project_1",
+ limit: 25,
+ });
+ expect(result.structuredContent).toMatchObject({
+ status: "ok",
+ rowCount: 1,
+ totalCandidateRows: 2,
+ });
+ });
+
+ it.each([
+ [
+ "traffic_acquisition",
+ () =>
+ tools.getGoogleAnalyticsTrafficAcquisitionTool.handler(
+ {
+ projectId: "project_1",
+ breakdown: "campaign",
+ comparePreviousPeriod: false,
+ limit: 100,
+ offset: 0,
+ },
+ toolExtra,
+ ),
+ ],
+ [
+ "ecommerce_performance",
+ () =>
+ tools.getGoogleAnalyticsEcommercePerformanceTool.handler(
+ {
+ projectId: "project_1",
+ breakdown: "landing_page",
+ channel: "organic_search",
+ onlyWithTransactions: false,
+ limit: 100,
+ offset: 0,
+ },
+ toolExtra,
+ ),
+ ],
+ [
+ "site_search",
+ () =>
+ tools.getGoogleAnalyticsSiteSearchTool.handler(
+ { projectId: "project_1", limit: 100, offset: 0 },
+ toolExtra,
+ ),
+ ],
+ [
+ "audience_breakdown",
+ () =>
+ tools.getGoogleAnalyticsAudienceBreakdownTool.handler(
+ {
+ projectId: "project_1",
+ breakdown: "country",
+ channel: "all",
+ comparePreviousPeriod: false,
+ limit: 100,
+ offset: 0,
+ },
+ toolExtra,
+ ),
+ ],
+ ] as const)(
+ "maps a controlled breakdown to %s",
+ async (expectedKind, run) => {
+ await run();
+ expect(mocks.runReport).toHaveBeenCalledWith(
+ expect.objectContaining({ kind: expectedKind }),
+ );
+ },
+ );
+
+ it("returns organic overview and measurement-health envelopes", async () => {
+ mocks.getOrganicOverview.mockResolvedValue({
+ status: "ok",
+ request: {
+ resolvedDateRange: { startDate: "2026-07-09", endDate: "2026-08-05" },
+ previousDateRange: { startDate: "2026-06-11", endDate: "2026-07-08" },
+ },
+ comparison: {},
+ trend: [],
+ });
+ mocks.getMeasurementHealth.mockResolvedValue({
+ status: "ok",
+ summary: { webStreamCount: 1, keyEventCount: 2, issueCount: 0 },
+ issues: [],
+ });
+ const {
+ getGoogleAnalyticsMeasurementHealthTool,
+ getGoogleAnalyticsOrganicOverviewTool,
+ } = tools;
+ const overview = await getGoogleAnalyticsOrganicOverviewTool.handler(
+ { projectId: "project_1", trend: "weekly" },
+ toolExtra,
+ );
+ const health = await getGoogleAnalyticsMeasurementHealthTool.handler(
+ { projectId: "project_1" },
+ toolExtra,
+ );
+ expect(mocks.getOrganicOverview).toHaveBeenCalledWith({
+ projectId: "project_1",
+ trend: "weekly",
+ });
+ expect(mocks.getMeasurementHealth).toHaveBeenCalledWith("project_1");
+ expect(overview.structuredContent).toMatchObject({ status: "ok" });
+ expect(health.structuredContent).toMatchObject({ status: "ok" });
+ });
+
+ it("rejects incomplete success and error envelopes", async () => {
+ const {
+ getGoogleAnalyticsMeasurementHealthTool,
+ getGoogleAnalyticsOrganicLandingPagesTool,
+ getGoogleAnalyticsOrganicOverviewTool,
+ getSearchOpportunitiesTool,
+ } = tools;
+
+ for (const tool of [
+ getGoogleAnalyticsOrganicLandingPagesTool,
+ getGoogleAnalyticsOrganicOverviewTool,
+ getGoogleAnalyticsMeasurementHealthTool,
+ getSearchOpportunitiesTool,
+ ]) {
+ expect(tool.config.outputSchema.safeParse({ status: "ok" }).success).toBe(
+ false,
+ );
+ expect(
+ tool.config.outputSchema.safeParse({ status: "error" }).success,
+ ).toBe(false);
+ expect(
+ tool.config.outputSchema.safeParse({
+ status: "error",
+ error: { code: "ga4_not_connected", message: "Connect GA4." },
+ additiveField: true,
+ }).success,
+ ).toBe(true);
+ }
+ });
+});
diff --git a/src/server/mcp/tools/google-analytics-tools.ts b/src/server/mcp/tools/google-analytics-tools.ts
new file mode 100644
index 0000000..b2b692d
--- /dev/null
+++ b/src/server/mcp/tools/google-analytics-tools.ts
@@ -0,0 +1,589 @@
+/* eslint-disable max-lines -- all GA4 MCP tools are intentionally kept in one module */
+import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
+import { z } from "zod";
+import { Ga4MeasurementHealthService } from "@/server/features/ga4/services/Ga4MeasurementHealthService";
+import { Ga4OrganicOverviewService } from "@/server/features/ga4/services/Ga4OrganicOverviewService";
+import {
+ GscApiError,
+ GscNotConnectedError,
+ GscTokenError,
+} from "@/server/lib/gscErrors";
+import {
+ Ga4ReportingService,
+ type Ga4ReportInput,
+} from "@/server/features/ga4/services/Ga4ReportingService";
+import { Ga4ReportError } from "@/server/lib/ga4Errors";
+import { SearchOpportunityService } from "@/server/features/ga4/services/SearchOpportunityService";
+import { buildProjectMeta } from "@/server/mcp/context";
+import { mcpResponse } from "@/server/mcp/formatters";
+import { looseObjectOutputSchema } from "@/server/mcp/output-schemas";
+import { withMcpProjectAuth } from "@/server/mcp/project-auth";
+import { projectIdSchema } from "@/server/mcp/schemas";
+import { buildDashboardUrl } from "@/server/mcp/urls";
+
+const dateSchema = z
+ .string()
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
+ .describe("Inclusive YYYY-MM-DD date. Provide both startDate and endDate.");
+
+const commonAnalyticsInputSchema = {
+ projectId: projectIdSchema,
+ startDate: dateSchema.optional(),
+ endDate: dateSchema.optional(),
+ limit: z.number().int().min(1).max(1_000).optional().default(100),
+ offset: z.number().int().min(0).optional().default(0),
+} as const;
+
+const errorDetailSchema = z
+ .object({
+ code: z.string(),
+ message: z.string(),
+ retryAfterSeconds: z.number().nullable().optional(),
+ actionUrl: z.string().optional(),
+ })
+ .passthrough();
+
+// The MCP SDK can only publish and validate a top-level object schema — a
+// discriminated union normalizes to undefined, which drops the schema from
+// tools/list and crashes output validation. So the ok/error branches share
+// one object, with the per-status required fields enforced by a refinement.
+function analyticsEnvelopeSchema(okShape: Record) {
+ const requiredOkFields = Object.entries(okShape)
+ .filter(([, field]) => !field.safeParse(undefined).success)
+ .map(([key]) => key);
+ const optionalShape = Object.fromEntries(
+ Object.entries(okShape).map(([key, field]) => [key, field.optional()]),
+ );
+ return z
+ .object({
+ status: z.enum(["ok", "error"]),
+ ...optionalShape,
+ error: errorDetailSchema.optional(),
+ })
+ .passthrough()
+ .superRefine((value, ctx) => {
+ if (value.status === "error") {
+ if (value.error === undefined) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["error"],
+ message: "error is required when status is error",
+ });
+ }
+ return;
+ }
+ for (const key of requiredOkFields) {
+ if ((value as Record)[key] === undefined) {
+ ctx.addIssue({
+ code: "custom",
+ path: [key],
+ message: `${key} is required when status is ok`,
+ });
+ }
+ }
+ });
+}
+
+const reportOutputSchema = analyticsEnvelopeSchema({
+ source: looseObjectOutputSchema,
+ request: looseObjectOutputSchema,
+ rowCount: z.number(),
+ totalRowCount: z.number(),
+ rows: z.array(z.record(z.string(), z.unknown())),
+ pageInfo: looseObjectOutputSchema,
+ reportMetadata: looseObjectOutputSchema,
+ warnings: z.array(z.string()),
+ quota: looseObjectOutputSchema.nullable().optional(),
+ comparison: looseObjectOutputSchema.optional(),
+ ecommerceActivity: looseObjectOutputSchema.optional(),
+ siteSearchActivity: looseObjectOutputSchema.optional(),
+ diagnostics: z.array(looseObjectOutputSchema).optional(),
+ diagnosticCoverage: looseObjectOutputSchema.optional(),
+});
+
+const overviewOutputSchema = analyticsEnvelopeSchema({
+ source: looseObjectOutputSchema,
+ request: looseObjectOutputSchema,
+ current: looseObjectOutputSchema.nullable(),
+ previous: looseObjectOutputSchema.nullable(),
+ comparison: looseObjectOutputSchema,
+ trend: z.array(z.record(z.string(), z.unknown())),
+ diagnostics: z.array(looseObjectOutputSchema),
+ reportMetadata: looseObjectOutputSchema,
+ warnings: z.array(z.string()),
+ quota: looseObjectOutputSchema.nullable().optional(),
+});
+
+const measurementHealthOutputSchema = analyticsEnvelopeSchema({
+ source: looseObjectOutputSchema,
+ summary: looseObjectOutputSchema,
+ issues: z.array(z.string()),
+ webStreams: z.array(z.record(z.string(), z.unknown())),
+ otherStreams: z.array(z.record(z.string(), z.unknown())),
+ keyEvents: z.array(z.record(z.string(), z.unknown())),
+ customDefinitions: looseObjectOutputSchema,
+});
+
+const opportunityOutputSchema = analyticsEnvelopeSchema({
+ source: looseObjectOutputSchema,
+ request: looseObjectOutputSchema,
+ rowCount: z.number(),
+ totalCandidateRows: z.number(),
+ rows: z.array(z.record(z.string(), z.unknown())),
+ scoring: looseObjectOutputSchema,
+ coverage: looseObjectOutputSchema,
+ truncated: looseObjectOutputSchema,
+ warnings: z.array(z.string()),
+ reportMetadata: looseObjectOutputSchema,
+ quota: looseObjectOutputSchema.nullable().optional(),
+});
+
+type ProjectContext = {
+ auth: { organizationId: string };
+ baseUrl: string;
+ project: unknown;
+};
+
+type ProjectArgs = { projectId: string };
+
+function actionUrl(
+ baseUrl: string,
+ projectId: string,
+ code: string,
+): string | undefined {
+ if (code.startsWith("ga4_")) {
+ if (
+ [
+ "ga4_not_connected",
+ "ga4_reconnect_required",
+ "ga4_property_inaccessible",
+ ].includes(code)
+ ) {
+ return buildDashboardUrl(baseUrl, `/p/${projectId}/settings`);
+ }
+ return undefined;
+ }
+ if (code.startsWith("gsc_")) {
+ return buildDashboardUrl(baseUrl, `/p/${projectId}/search-performance`);
+ }
+ return undefined;
+}
+
+function errorResponse(
+ args: ProjectArgs,
+ context: ProjectContext,
+ error: unknown,
+): CallToolResult {
+ let code: string;
+ let message: string;
+ let retryAfterSeconds: number | null | undefined;
+ if (error instanceof Ga4ReportError) {
+ code = error.code;
+ message = error.message;
+ retryAfterSeconds = error.retryAfterSeconds;
+ } else if (error instanceof GscNotConnectedError) {
+ code = "gsc_not_connected";
+ message = "Search Console is not connected for this project.";
+ } else if (
+ error instanceof GscTokenError ||
+ (error instanceof GscApiError && [401, 403].includes(error.status))
+ ) {
+ code = "gsc_reconnect_required";
+ message = "The Search Console connection has expired or was revoked.";
+ } else if (error instanceof GscApiError) {
+ code = "gsc_upstream_unavailable";
+ message = "Search Console reporting is temporarily unavailable.";
+ } else {
+ throw error;
+ }
+ const url = actionUrl(context.baseUrl, args.projectId, code);
+ return mcpResponse({
+ text: `${message}${url ? ` Continue here: ${url}` : ""}`,
+ meta: buildProjectMeta(context, args.projectId),
+ structuredContent: {
+ status: "error",
+ error: {
+ code,
+ message,
+ retryAfterSeconds,
+ actionUrl: url,
+ },
+ },
+ });
+}
+
+function reportText(
+ label: string,
+ result: Awaited>,
+) {
+ const range = result.request.resolvedDateRange;
+ const comparison = result.comparison
+ ? ` Previous-period comparison returned ${result.comparison.rows.length} row(s).`
+ : "";
+ const diagnostics =
+ result.diagnostics.length > 0
+ ? ` ${result.diagnostics.length} diagnostic finding(s) are included.`
+ : "";
+ return `${label}: ${result.rowCount} of ${result.totalRowCount} rows for ${range.startDate} through ${range.endDate}.${comparison}${diagnostics}${result.reportMetadata.hasLimitedData ? " Google marked this report as limited; inspect reportMetadata." : ""}`;
+}
+
+function createAnalyticsReportHandler(
+ label: string,
+ toInput: (args: TArgs) => Ga4ReportInput,
+) {
+ return withMcpProjectAuth(async (args: TArgs, context) => {
+ try {
+ const result = await Ga4ReportingService.runReport(toInput(args));
+ return mcpResponse({
+ text: reportText(label, result),
+ meta: buildProjectMeta(context, args.projectId),
+ structuredContent: result,
+ });
+ } catch (error) {
+ return errorResponse(args, context, error);
+ }
+ });
+}
+
+const landingPageInputSchema = z.strictObject(commonAnalyticsInputSchema);
+type LandingPageArgs = z.infer;
+
+export const getGoogleAnalyticsOrganicLandingPagesTool = {
+ name: "get_google_analytics_organic_landing_pages",
+ config: {
+ title: "Get Google Analytics organic landing pages",
+ description:
+ "Read organic-search landing page sessions, engagement, key events, transactions, and revenue from the project's connected GA4 property. Defaults to the last 28 complete property days. Read-only and uses no OpenSEO credits.",
+ inputSchema: landingPageInputSchema,
+ outputSchema: reportOutputSchema,
+ annotations: {
+ readOnlyHint: true,
+ openWorldHint: true,
+ destructiveHint: false,
+ },
+ },
+ handler: createAnalyticsReportHandler(
+ "Organic landing pages",
+ (args) => ({
+ ...args,
+ kind: "landing_pages",
+ channel: "organic_search",
+ }),
+ ),
+};
+
+const pagePerformanceInputSchema = z.strictObject({
+ ...commonAnalyticsInputSchema,
+ includeDate: z.boolean().optional().default(false),
+ channel: z
+ .enum(["organic_search", "all"])
+ .optional()
+ .default("organic_search"),
+});
+type PagePerformanceArgs = z.infer;
+
+export const getGoogleAnalyticsPagePerformanceTool = {
+ name: "get_google_analytics_page_performance",
+ config: {
+ title: "Get Google Analytics page performance",
+ description:
+ "Read page views, users, engagement duration, and key events from the connected GA4 property. Organic Search is the default; set channel to all to include every channel. Read-only and uses no OpenSEO credits.",
+ inputSchema: pagePerformanceInputSchema,
+ outputSchema: reportOutputSchema,
+ annotations: {
+ readOnlyHint: true,
+ openWorldHint: true,
+ destructiveHint: false,
+ },
+ },
+ handler: createAnalyticsReportHandler(
+ "Page performance",
+ (args) => ({
+ ...args,
+ kind: "page_performance",
+ }),
+ ),
+};
+
+const keyEventsInputSchema = z.strictObject({
+ ...commonAnalyticsInputSchema,
+ breakdown: z
+ .enum(["event", "event_and_landing_page"])
+ .optional()
+ .default("event"),
+ channel: z
+ .enum(["organic_search", "all"])
+ .optional()
+ .default("organic_search"),
+ comparePreviousPeriod: z.boolean().optional().default(false),
+});
+type KeyEventsArgs = z.infer;
+
+export const getGoogleAnalyticsKeyEventsTool = {
+ name: "get_google_analytics_key_events",
+ config: {
+ title: "Get Google Analytics key events",
+ description:
+ "Read active GA4 key events with counts and users by event or organic landing page. Previous-period comparison is available for the event breakdown. Read-only and uses no OpenSEO credits.",
+ inputSchema: keyEventsInputSchema,
+ outputSchema: reportOutputSchema,
+ annotations: {
+ readOnlyHint: true,
+ openWorldHint: true,
+ destructiveHint: false,
+ },
+ },
+ handler: createAnalyticsReportHandler(
+ "Key events",
+ (args) => ({
+ ...args,
+ kind: "key_events",
+ }),
+ ),
+};
+
+const opportunityInputSchema = z.strictObject({
+ projectId: projectIdSchema,
+ startDate: dateSchema.optional(),
+ endDate: dateSchema.optional(),
+ limit: z.number().int().min(1).max(100).optional().default(50),
+});
+type OpportunityArgs = z.infer;
+
+export const getSearchOpportunitiesTool = {
+ name: "get_search_opportunities",
+ config: {
+ title: "Get search opportunities",
+ description:
+ "Join Search Console pages ranking in positions 4–20 with GA4 organic landing-page outcomes, then score matched opportunities by demand, business value, and reachability. Unmatched pages remain visible and unscored. Read-only and uses no OpenSEO credits.",
+ inputSchema: opportunityInputSchema,
+ outputSchema: opportunityOutputSchema,
+ annotations: {
+ readOnlyHint: true,
+ openWorldHint: true,
+ destructiveHint: false,
+ },
+ },
+ handler: withMcpProjectAuth(async (args: OpportunityArgs, context) => {
+ try {
+ const result = await SearchOpportunityService.getOpportunities(args);
+ return mcpResponse({
+ text: `Search opportunities: ${result.rowCount} returned from ${result.totalCandidateRows} candidates. ${result.coverage.matchedRows} candidates matched GA4 landing pages.`,
+ meta: buildProjectMeta(context, args.projectId),
+ structuredContent: result,
+ });
+ } catch (error) {
+ return errorResponse(args, context, error);
+ }
+ }),
+};
+
+const overviewInputSchema = z.strictObject({
+ projectId: projectIdSchema,
+ startDate: dateSchema.optional(),
+ endDate: dateSchema.optional(),
+ trend: z.enum(["daily", "weekly"]).optional().default("daily"),
+});
+type OverviewArgs = z.infer;
+
+export const getGoogleAnalyticsOrganicOverviewTool = {
+ name: "get_google_analytics_organic_overview",
+ config: {
+ title: "Get Google Analytics organic overview",
+ description:
+ "Answer whether organic traffic is improving with top-line sessions, users, engagement, key events, transactions, revenue, an equal-length previous-period comparison, and a daily or weekly trend. Read-only and uses no OpenSEO credits.",
+ inputSchema: overviewInputSchema,
+ outputSchema: overviewOutputSchema,
+ annotations: {
+ readOnlyHint: true,
+ openWorldHint: true,
+ destructiveHint: false,
+ },
+ },
+ handler: withMcpProjectAuth(async (args: OverviewArgs, context) => {
+ try {
+ const result = await Ga4OrganicOverviewService.getOrganicOverview(args);
+ return mcpResponse({
+ text: `Organic overview for ${result.request.resolvedDateRange.startDate} through ${result.request.resolvedDateRange.endDate}, compared with ${result.request.previousDateRange.startDate} through ${result.request.previousDateRange.endDate}.`,
+ meta: buildProjectMeta(context, args.projectId),
+ structuredContent: result,
+ });
+ } catch (error) {
+ return errorResponse(args, context, error);
+ }
+ }),
+};
+
+const trafficAcquisitionInputSchema = z.strictObject({
+ ...commonAnalyticsInputSchema,
+ breakdown: z
+ .enum(["channel_group", "source_medium", "campaign"])
+ .optional()
+ .default("channel_group"),
+ comparePreviousPeriod: z.boolean().optional().default(false),
+});
+type TrafficAcquisitionArgs = z.infer;
+
+export const getGoogleAnalyticsTrafficAcquisitionTool = {
+ name: "get_google_analytics_traffic_acquisition",
+ config: {
+ title: "Get Google Analytics traffic acquisition",
+ description:
+ "Compare session acquisition by channel group, source/medium, or campaign, including sessions, users, engagement, key events, transactions, and revenue. Previous-period comparison is available for channel group; source/medium also reports attribution-quality diagnostics. Read-only and uses no OpenSEO credits.",
+ inputSchema: trafficAcquisitionInputSchema,
+ outputSchema: reportOutputSchema,
+ annotations: {
+ readOnlyHint: true,
+ openWorldHint: true,
+ destructiveHint: false,
+ },
+ },
+ handler: createAnalyticsReportHandler(
+ "Traffic acquisition",
+ (args) => ({
+ ...args,
+ kind: "traffic_acquisition",
+ channel: "all",
+ acquisitionBreakdown: args.breakdown,
+ breakdown: undefined,
+ }),
+ ),
+};
+
+const ecommerceInputSchema = z.strictObject({
+ ...commonAnalyticsInputSchema,
+ breakdown: z.enum(["item", "landing_page"]).optional().default("item"),
+ onlyWithTransactions: z.boolean().optional().default(false),
+ channel: z
+ .enum(["organic_search", "all"])
+ .optional()
+ .default("organic_search"),
+});
+type EcommerceArgs = z.infer;
+
+export const getGoogleAnalyticsEcommercePerformanceTool = {
+ name: "get_google_analytics_ecommerce_performance",
+ config: {
+ title: "Get Google Analytics ecommerce performance",
+ description:
+ "Read item views, add-to-cart units, purchases, and item revenue by item, or transactions and purchase revenue by landing page. Returns a detected, none, or unknown activity state; landing pages can be limited to those with transactions. Organic Search is the default. Read-only and uses no OpenSEO credits.",
+ inputSchema: ecommerceInputSchema,
+ outputSchema: reportOutputSchema,
+ annotations: {
+ readOnlyHint: true,
+ openWorldHint: true,
+ destructiveHint: false,
+ },
+ },
+ handler: createAnalyticsReportHandler(
+ "Ecommerce performance",
+ (args) => ({
+ ...args,
+ kind: "ecommerce_performance",
+ ecommerceBreakdown: args.breakdown,
+ ecommerceOnlyWithTransactions: args.onlyWithTransactions,
+ breakdown: undefined,
+ }),
+ ),
+};
+
+const siteSearchInputSchema = z.strictObject(commonAnalyticsInputSchema);
+type SiteSearchArgs = z.infer;
+
+export const getGoogleAnalyticsSiteSearchTool = {
+ name: "get_google_analytics_site_search",
+ config: {
+ title: "Get Google Analytics site search",
+ description:
+ "Read measured internal search terms with search events, users, sessions, engaged sessions, and engagement rate. Requires GA4 site-search measurement. Read-only and uses no OpenSEO credits.",
+ inputSchema: siteSearchInputSchema,
+ outputSchema: reportOutputSchema,
+ annotations: {
+ readOnlyHint: true,
+ openWorldHint: true,
+ destructiveHint: false,
+ },
+ },
+ handler: createAnalyticsReportHandler(
+ "Site search",
+ (args) => ({
+ ...args,
+ kind: "site_search",
+ channel: "all",
+ }),
+ ),
+};
+
+const audienceInputSchema = z.strictObject({
+ ...commonAnalyticsInputSchema,
+ breakdown: z
+ .enum(["device", "country", "new_vs_returning"])
+ .optional()
+ .default("device"),
+ channel: z
+ .enum(["organic_search", "all"])
+ .optional()
+ .default("organic_search"),
+ comparePreviousPeriod: z.boolean().optional().default(false),
+});
+type AudienceArgs = z.infer;
+
+export const getGoogleAnalyticsAudienceBreakdownTool = {
+ name: "get_google_analytics_audience_breakdown",
+ config: {
+ title: "Get Google Analytics audience breakdown",
+ description:
+ "Read device, country, or new-versus-returning users, sessions, engagement, and key events. Previous-period comparison is available for device and new-versus-returning breakdowns. No demographic or user-level dimensions. Read-only and uses no OpenSEO credits.",
+ inputSchema: audienceInputSchema,
+ outputSchema: reportOutputSchema,
+ annotations: {
+ readOnlyHint: true,
+ openWorldHint: true,
+ destructiveHint: false,
+ },
+ },
+ handler: createAnalyticsReportHandler(
+ "Audience breakdown",
+ (args) => ({
+ ...args,
+ kind: "audience_breakdown",
+ audienceBreakdown: args.breakdown,
+ breakdown: undefined,
+ }),
+ ),
+};
+
+const measurementHealthInputSchema = z.strictObject({
+ projectId: projectIdSchema,
+});
+type MeasurementHealthArgs = z.infer;
+
+export const getGoogleAnalyticsMeasurementHealthTool = {
+ name: "get_google_analytics_measurement_health",
+ config: {
+ title: "Get Google Analytics measurement health",
+ description:
+ "Diagnose the connected property's data streams, web measurement IDs, enhanced-measurement settings, key events, and custom definitions. Read-only and uses no OpenSEO credits.",
+ inputSchema: measurementHealthInputSchema,
+ outputSchema: measurementHealthOutputSchema,
+ annotations: {
+ readOnlyHint: true,
+ openWorldHint: true,
+ destructiveHint: false,
+ },
+ },
+ handler: withMcpProjectAuth(async (args: MeasurementHealthArgs, context) => {
+ try {
+ const result = await Ga4MeasurementHealthService.getMeasurementHealth(
+ args.projectId,
+ );
+ return mcpResponse({
+ text: `Measurement health: ${result.summary.webStreamCount} web stream(s), ${result.summary.keyEventCount} key event(s), and ${result.summary.issueCount} diagnostic issue(s).`,
+ meta: buildProjectMeta(context, args.projectId),
+ structuredContent: result,
+ });
+ } catch (error) {
+ return errorResponse(args, context, error);
+ }
+ }),
+};
diff --git a/src/server/mcp/tools/output-schema-validation.test.ts b/src/server/mcp/tools/output-schema-validation.test.ts
index 3713942..5b8c39c 100644
--- a/src/server/mcp/tools/output-schema-validation.test.ts
+++ b/src/server/mcp/tools/output-schema-validation.test.ts
@@ -2,10 +2,11 @@ import {
normalizeObjectSchema,
safeParseAsync,
} from "@modelcontextprotocol/sdk/server/zod-compat.js";
-import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
-import type { ToolExtra } from "@/server/mcp/context";
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
+import { AppError } from "@/server/lib/errors";
+import * as researchTools from "./dataforseo-research-tools";
+import { getBacklinksProfileTool } from "./get-backlinks-profile";
+import { makeMcpAuthContext, makeToolExtra } from "./tool-test-support";
const mocks = vi.hoisted(() => ({
getProjectForOrganization: vi.fn(),
@@ -41,32 +42,12 @@ class ProviderRow {
) {}
}
-const authContext = {
- userId: "user_123",
- userEmail: "team@example.com",
- organizationId: "org_123",
- clientId: "client_123",
- scopes: ["mcp"],
- audience: "open-seo",
- subject: "user_123",
- baseUrl: "https://app.example.com",
-};
-
-const authExtra: ToolExtra = {
- signal: new AbortController().signal,
- requestId: 1,
- sendNotification: vi.fn(),
- sendRequest: vi.fn(),
- authInfo: {
- token: "token",
- clientId: "client_123",
- scopes: ["mcp"],
- resource: new URL("https://app.example.com/mcp"),
- extra: {
- [MCP_AUTH_CONTEXT_PROP]: authContext,
- },
- } satisfies AuthInfo,
-};
+const authExtra = makeToolExtra(
+ makeMcpAuthContext({
+ userEmail: "team@example.com",
+ baseUrl: "https://app.example.com",
+ }),
+);
const backlinkPage = {
rows: [
@@ -97,8 +78,6 @@ const backlinkPage = {
};
beforeEach(() => {
- mocks.getProjectForOrganization.mockReset();
- mocks.profileBacklinksPage.mockReset();
mocks.getProjectForOrganization.mockResolvedValue({
id: "project_123",
locationCode: 2840,
@@ -117,7 +96,7 @@ describe("DataForSEO research tool output schemas", () => {
])(
"%s accepts typed (non-plain-object) provider rows",
async (toolName, field) => {
- const tools = await import("./dataforseo-research-tools");
+ const tools = researchTools;
const tool = Object.values(tools).find((t) => t.name === toolName);
if (!tool) throw new Error(`tool ${toolName} not found`);
@@ -137,7 +116,6 @@ describe("DataForSEO research tool output schemas", () => {
);
it("get_backlinks_profile accepts a paginated backlinks profile payload", async () => {
- const { getBacklinksProfileTool } = await import("./get-backlinks-profile");
const schema = normalizeObjectSchema(
getBacklinksProfileTool.config.outputSchema,
);
@@ -159,7 +137,6 @@ describe("DataForSEO research tool output schemas", () => {
describe("get_backlinks_profile MCP tool", () => {
it("returns paginated backlink rows and honors filters, sorting, and mode", async () => {
mocks.profileBacklinksPage.mockResolvedValue(backlinkPage);
- const { getBacklinksProfileTool } = await import("./get-backlinks-profile");
const result = await getBacklinksProfileTool.handler(
{
@@ -217,7 +194,6 @@ describe("get_backlinks_profile MCP tool", () => {
page: 2,
};
mocks.profileBacklinksPage.mockResolvedValue(finalPage);
- const { getBacklinksProfileTool } = await import("./get-backlinks-profile");
const result = await getBacklinksProfileTool.handler(
{
@@ -244,13 +220,11 @@ describe("get_backlinks_profile MCP tool", () => {
});
it("preserves Backlinks API access and credit errors", async () => {
- const { AppError } = await import("@/server/lib/errors");
const error = new AppError(
"BACKLINKS_BILLING_ISSUE",
"The connected DataForSEO account has a billing or balance issue",
);
mocks.profileBacklinksPage.mockRejectedValue(error);
- const { getBacklinksProfileTool } = await import("./get-backlinks-profile");
await expect(
getBacklinksProfileTool.handler(
diff --git a/src/server/mcp/tools/rank-tracking-management-tools.test.ts b/src/server/mcp/tools/rank-tracking-management-tools.test.ts
new file mode 100644
index 0000000..7c870d0
--- /dev/null
+++ b/src/server/mcp/tools/rank-tracking-management-tools.test.ts
@@ -0,0 +1,286 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { z } from "zod";
+import { addRankTrackingKeywordsTool } from "./add-rank-tracking-keywords";
+import { createRankTrackerTool } from "./create-rank-tracker";
+import { estimateRankTrackerCostTool } from "./estimate-rank-tracker-cost";
+import { removeRankTrackingKeywordsTool } from "./remove-rank-tracking-keywords";
+import { runRankTrackerTool } from "./run-rank-tracker";
+import { makeToolExtra, textContent } from "./tool-test-support";
+
+const mocks = vi.hoisted(() => ({
+ getProjectForOrganization: vi.fn(),
+ createConfig: vi.fn(),
+ getTracker: vi.fn(),
+ addKeywords: vi.fn(),
+ removeKeywords: vi.fn(),
+ estimateCost: vi.fn(),
+ triggerCheck: vi.fn(),
+ captureServerEvent: vi.fn(),
+ waitUntil: vi.fn((promise: Promise) => void promise.catch(() => {})),
+}));
+
+vi.mock("cloudflare:workers", () => ({
+ env: {},
+ waitUntil: mocks.waitUntil,
+}));
+vi.mock("@/server/features/projects/services/ProjectService", () => ({
+ ProjectService: {
+ getProjectForOrganization: mocks.getProjectForOrganization,
+ },
+}));
+vi.mock("@/server/features/rank-tracking/services/RankTrackingService", () => ({
+ RankTrackingService: {
+ createConfig: mocks.createConfig,
+ getTracker: mocks.getTracker,
+ addKeywords: mocks.addKeywords,
+ removeKeywords: mocks.removeKeywords,
+ estimateCost: mocks.estimateCost,
+ triggerCheck: mocks.triggerCheck,
+ },
+}));
+vi.mock("@/server/lib/posthog", () => ({
+ captureServerEvent: mocks.captureServerEvent,
+}));
+
+const projectId = "11111111-1111-4111-8111-111111111111";
+const trackerId = "22222222-2222-4222-8222-222222222222";
+const keywordId = "33333333-3333-4333-8333-333333333333";
+
+const toolExtra = makeToolExtra();
+
+const createdConfig = {
+ id: trackerId,
+ projectId,
+ domain: "openseo.so",
+ locationCode: 2840,
+ languageCode: "en",
+ locationName: null,
+ devices: "mobile" as const,
+ serpDepth: 40,
+ scheduleInterval: "manual" as const,
+ isActive: true,
+};
+
+describe("rank tracking management MCP tools", () => {
+ beforeEach(() => {
+ mocks.getProjectForOrganization.mockResolvedValue({
+ id: projectId,
+ domain: "openseo.so",
+ locationCode: 2840,
+ languageCode: "en",
+ });
+ mocks.captureServerEvent.mockResolvedValue(undefined);
+ });
+
+ it("creates a manual tracker from project defaults without spending credits", async () => {
+ mocks.createConfig.mockResolvedValue(createdConfig);
+
+ const parsed = z.object(createRankTrackerTool.config.inputSchema).parse({
+ projectId,
+ });
+ const result = await createRankTrackerTool.handler(parsed, toolExtra);
+
+ expect(mocks.createConfig).toHaveBeenCalledWith({
+ projectId,
+ projectMarket: {
+ id: projectId,
+ domain: "openseo.so",
+ locationCode: 2840,
+ languageCode: "en",
+ },
+ domain: "openseo.so",
+ locationCode: undefined,
+ languageCode: undefined,
+ locationName: undefined,
+ devices: "mobile",
+ serpDepth: 40,
+ scheduleInterval: "manual",
+ });
+ expect(textContent(result)).toContain("no check was started");
+ expect(result.structuredContent).toMatchObject({
+ trackerId,
+ config: createdConfig,
+ });
+ expect(mocks.getTracker).not.toHaveBeenCalled();
+ expect(mocks.captureServerEvent).toHaveBeenCalledWith({
+ distinctId: "user_123",
+ event: "rank_tracking:config_create",
+ organizationId: "org_123",
+ properties: {
+ project_id: projectId,
+ domain: "openseo.so",
+ devices: "mobile",
+ schedule: "manual",
+ source: "mcp",
+ },
+ });
+ expect(
+ createRankTrackerTool.config.outputSchema.safeParse(
+ result.structuredContent,
+ ).success,
+ ).toBe(true);
+ });
+
+ it("rejects tracker creation when neither the call nor project has a domain", async () => {
+ mocks.getProjectForOrganization.mockResolvedValue({
+ id: projectId,
+ domain: null,
+ locationCode: 2840,
+ languageCode: "en",
+ });
+
+ await expect(
+ createRankTrackerTool.handler({ projectId }, toolExtra),
+ ).rejects.toMatchObject({ code: "VALIDATION_ERROR" });
+ expect(mocks.createConfig).not.toHaveBeenCalled();
+ });
+
+ it("requires maxCostCredits to run a rank tracker", () => {
+ expect(
+ z.object(runRankTrackerTool.config.inputSchema).safeParse({
+ projectId,
+ trackerId,
+ }).success,
+ ).toBe(false);
+ });
+
+ it("reports database-confirmed add and removal counts in text and structured output", async () => {
+ mocks.addKeywords.mockResolvedValue({ added: 1, addedIds: [keywordId] });
+ mocks.removeKeywords.mockResolvedValue({
+ removed: 1,
+ removedIds: [keywordId],
+ });
+
+ const added = await addRankTrackingKeywordsTool.handler(
+ { projectId, trackerId, keywords: ["seo", "SEO", "existing"] },
+ toolExtra,
+ );
+ expect(textContent(added)).toContain("Added 1 of 3 requested");
+ expect(added.structuredContent).toMatchObject({ requested: 3, added: 1 });
+ expect(mocks.addKeywords).toHaveBeenCalledWith(
+ trackerId,
+ projectId,
+ ["seo", "SEO", "existing"],
+ {
+ kind: "credit_ceiling",
+ maxEstimatedScheduledCheckCredits: undefined,
+ },
+ );
+
+ const removed = await removeRankTrackingKeywordsTool.handler(
+ { projectId, trackerId, keywordIds: [keywordId, keywordId] },
+ toolExtra,
+ );
+ expect(textContent(removed)).toContain("Removed 1 of 2 requested");
+ expect(removed.structuredContent).toMatchObject({
+ requested: 2,
+ removed: 1,
+ removedIds: [keywordId],
+ });
+ });
+
+ it("returns the shared live cost estimate without starting a check", async () => {
+ mocks.estimateCost.mockResolvedValue({
+ costUsd: 0.0128,
+ costCredits: 13,
+ keywordCount: 8,
+ devicesCount: 2,
+ totalChecks: 16,
+ method: "live",
+ existingKeywordCount: 5,
+ additionalKeywordCount: 3,
+ scheduledEstimate: {
+ scheduleInterval: "weekly",
+ costUsd: 0.0046,
+ costCredits: 5,
+ checksPerMonth: 4,
+ monthlyCostUsd: 0.0184,
+ monthlyCostCredits: 20,
+ },
+ });
+ const result = await estimateRankTrackerCostTool.handler(
+ { projectId, trackerId, additionalKeywordCount: 3 },
+ toolExtra,
+ );
+
+ expect(textContent(result)).toContain(
+ "8 keywords × 2 devices = 16 SERP checks",
+ );
+ expect(textContent(result)).toContain(
+ "additional separately billed live fallback",
+ );
+ expect(result.structuredContent).toMatchObject({
+ costCredits: 13,
+ method: "live",
+ });
+ expect(mocks.estimateCost).toHaveBeenCalledWith(trackerId, projectId, 3);
+ expect(mocks.triggerCheck).not.toHaveBeenCalled();
+ });
+
+ it("returns the created run ID and emits the existing telemetry contract", async () => {
+ mocks.triggerCheck.mockResolvedValue({
+ ok: true,
+ runId: "run_1",
+ });
+ const result = await runRankTrackerTool.handler(
+ { projectId, trackerId, maxCostCredits: 13 },
+ toolExtra,
+ );
+
+ expect(result.structuredContent).toMatchObject({
+ started: true,
+ runId: "run_1",
+ });
+ expect(mocks.triggerCheck).toHaveBeenCalledWith(
+ expect.objectContaining({ maxCostCredits: 13 }),
+ );
+ expect(mocks.captureServerEvent).toHaveBeenCalledWith({
+ distinctId: "user_123",
+ event: "rank_tracking:check_trigger",
+ organizationId: "org_123",
+ properties: {
+ project_id: projectId,
+ config_id: trackerId,
+ run_id: "run_1",
+ source: "mcp",
+ },
+ });
+ expect(mocks.waitUntil).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not emit telemetry or imply another charge for an active run", async () => {
+ mocks.triggerCheck.mockResolvedValue({
+ ok: false,
+ reason: "already_running",
+ blockingRunId: "run_0",
+ });
+ const result = await runRankTrackerTool.handler(
+ { projectId, trackerId, maxCostCredits: 13 },
+ toolExtra,
+ );
+
+ expect(textContent(result)).toContain("no additional check was charged");
+ expect(result.structuredContent).toMatchObject({
+ started: false,
+ blockingRunId: "run_0",
+ });
+ expect(mocks.captureServerEvent).not.toHaveBeenCalled();
+ });
+
+ it("returns a started run even when deferred telemetry rejects", async () => {
+ mocks.triggerCheck.mockResolvedValue({
+ ok: true,
+ runId: "run_1",
+ });
+ mocks.captureServerEvent.mockRejectedValue(new Error("telemetry down"));
+
+ await expect(
+ runRankTrackerTool.handler(
+ { projectId, trackerId, maxCostCredits: 13 },
+ toolExtra,
+ ),
+ ).resolves.toMatchObject({
+ structuredContent: { started: true, runId: "run_1" },
+ });
+ });
+});
diff --git a/src/server/mcp/tools/remove-rank-tracking-keywords.ts b/src/server/mcp/tools/remove-rank-tracking-keywords.ts
new file mode 100644
index 0000000..8c30181
--- /dev/null
+++ b/src/server/mcp/tools/remove-rank-tracking-keywords.ts
@@ -0,0 +1,69 @@
+import { z } from "zod";
+import { RankTrackingService } from "@/server/features/rank-tracking/services/RankTrackingService";
+import { buildProjectMeta } from "@/server/mcp/context";
+import { mcpResponse } from "@/server/mcp/formatters";
+import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";
+import { withMcpProjectAuth } from "@/server/mcp/project-auth";
+import { projectIdSchema } from "@/server/mcp/schemas";
+
+const inputSchema = {
+ projectId: projectIdSchema,
+ trackerId: z
+ .string()
+ .uuid()
+ .describe("Rank tracker ID from get_rank_tracker."),
+ keywordIds: z
+ .array(z.string().uuid())
+ .min(1)
+ .max(2000)
+ .describe(
+ "Tracking keyword IDs to remove. Use `trackingKeywordId` values returned by get_rank_tracker.",
+ ),
+} as const;
+
+type Args = z.infer>;
+
+export const removeRankTrackingKeywordsTool = {
+ name: "remove_rank_tracking_keywords",
+ config: {
+ title: "Remove rank tracking keywords",
+ description:
+ "Stop tracking keywords by their trackingKeywordId. Uses no credits and preserves historical snapshots. Missing, stale, foreign, and repeated IDs are ignored; `removed` is the number actually deleted from this tracker.",
+ inputSchema,
+ outputSchema: z
+ .object({
+ trackerId: z.string(),
+ requested: z.number(),
+ removed: z.number(),
+ removedIds: z.array(z.string()),
+ ...optionalMetaOutputSchema,
+ })
+ .passthrough(),
+ annotations: {
+ readOnlyHint: false,
+ openWorldHint: false,
+ destructiveHint: true,
+ },
+ },
+ handler: withMcpProjectAuth(async (args: Args, context) => {
+ const result = await RankTrackingService.removeKeywords(
+ args.trackerId,
+ args.projectId,
+ args.keywordIds,
+ );
+ const requested = args.keywordIds.length;
+ return mcpResponse({
+ text: `Removed ${result.removed} of ${requested} requested keyword ID${requested === 1 ? "" : "s"} from tracker ${args.trackerId}. Historical snapshots were preserved.`,
+ meta: buildProjectMeta(
+ context,
+ args.projectId,
+ `/p/${args.projectId}/rank-tracking/${args.trackerId}`,
+ ),
+ structuredContent: {
+ trackerId: args.trackerId,
+ requested,
+ ...result,
+ },
+ });
+ }),
+};
diff --git a/src/server/mcp/tools/run-rank-tracker.ts b/src/server/mcp/tools/run-rank-tracker.ts
new file mode 100644
index 0000000..2c9c799
--- /dev/null
+++ b/src/server/mcp/tools/run-rank-tracker.ts
@@ -0,0 +1,95 @@
+import { z } from "zod";
+import { waitUntil } from "cloudflare:workers";
+import { RankTrackingService } from "@/server/features/rank-tracking/services/RankTrackingService";
+import { captureServerEvent } from "@/server/lib/posthog";
+import { buildProjectMeta } from "@/server/mcp/context";
+import { mcpResponse } from "@/server/mcp/formatters";
+import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";
+import { withMcpProjectAuth } from "@/server/mcp/project-auth";
+import { projectIdSchema } from "@/server/mcp/schemas";
+
+const inputSchema = {
+ projectId: projectIdSchema,
+ trackerId: z
+ .string()
+ .uuid()
+ .describe("Rank tracker ID from get_rank_tracker."),
+ maxCostCredits: z
+ .number()
+ .int()
+ .positive()
+ .describe(
+ "Maximum credits the user approved after seeing estimate_rank_tracker_cost. The run is rejected if its fresh estimate is higher.",
+ ),
+} as const;
+
+type Args = z.infer>;
+
+export const runRankTrackerTool = {
+ name: "run_rank_tracker",
+ config: {
+ title: "Run rank tracker",
+ description:
+ "Start an explicit live rank check for every keyword and configured device. This spends credits: call estimate_rank_tracker_cost, show the estimate to the user, and pass the approved credit amount as maxCostCredits. A fresh estimate above that ceiling is rejected. Hosted accounts require a paid plan, while self-hosted deployments are not plan-gated. If a run is already in progress, its blocking run ID is reported without starting or charging another check. The schedule is unchanged.",
+ inputSchema,
+ outputSchema: z
+ .object({
+ trackerId: z.string(),
+ started: z.boolean(),
+ runId: z.string().optional(),
+ blockingRunId: z.string().nullable().optional(),
+ ...optionalMetaOutputSchema,
+ })
+ .passthrough(),
+ annotations: {
+ readOnlyHint: false,
+ openWorldHint: true,
+ destructiveHint: false,
+ },
+ },
+ handler: withMcpProjectAuth(async (args: Args, context) => {
+ const result = await RankTrackingService.triggerCheck({
+ configId: args.trackerId,
+ projectId: args.projectId,
+ billingCustomer: context.billing,
+ maxCostCredits: args.maxCostCredits,
+ });
+ const trackerPath = `/p/${args.projectId}/rank-tracking/${args.trackerId}`;
+
+ if (!result.ok) {
+ return mcpResponse({
+ text: `A rank check is already running for tracker ${args.trackerId}${result.blockingRunId ? ` (run ${result.blockingRunId})` : ""}. No new run was created and no additional check was charged. Poll get_rank_tracker until lastCheckedAt advances.`,
+ meta: buildProjectMeta(context, args.projectId, trackerPath),
+ structuredContent: {
+ trackerId: args.trackerId,
+ started: false,
+ blockingRunId: result.blockingRunId,
+ },
+ });
+ }
+
+ waitUntil(
+ captureServerEvent({
+ distinctId: context.auth.userId,
+ event: "rank_tracking:check_trigger",
+ organizationId: context.auth.organizationId,
+ properties: {
+ project_id: args.projectId,
+ config_id: args.trackerId,
+ run_id: result.runId,
+ source: "mcp",
+ },
+ }),
+ );
+
+ return mcpResponse({
+ text: `Rank check ${result.runId} started for tracker ${args.trackerId}. Poll get_rank_tracker until lastCheckedAt advances, then read the updated positions.`,
+ meta: buildProjectMeta(context, args.projectId, trackerPath),
+ structuredContent: {
+ trackerId: args.trackerId,
+ started: true,
+ runId: result.runId,
+ },
+ });
+ }),
+};
diff --git a/src/server/mcp/tools/saved-keywords-tools.test.ts b/src/server/mcp/tools/saved-keywords-tools.test.ts
index 2632a00..8983b69 100644
--- a/src/server/mcp/tools/saved-keywords-tools.test.ts
+++ b/src/server/mcp/tools/saved-keywords-tools.test.ts
@@ -1,7 +1,7 @@
-import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
-import type { ToolExtra } from "@/server/mcp/context";
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
+import { listSavedKeywordsTool } from "./list-saved-keywords";
+import { saveKeywordsTool } from "./save-keywords";
+import { makeToolExtra } from "./tool-test-support";
const mocks = vi.hoisted(() => ({
getProjectForOrganization: vi.fn(),
@@ -22,42 +22,15 @@ vi.mock("@/server/features/keywords/services/KeywordResearchService", () => ({
},
}));
-const authContext = {
- userId: "user_123",
- userEmail: "alice@example.com",
- organizationId: "org_123",
- clientId: "client_123",
- scopes: ["mcp"],
- audience: "https://open-seo.test/mcp",
- subject: "user_123",
- baseUrl: "https://open-seo.test",
-};
-
-const toolExtra: ToolExtra = {
- signal: new AbortController().signal,
- requestId: 1,
- sendNotification: vi.fn(),
- sendRequest: vi.fn(),
- authInfo: {
- token: "token",
- clientId: "client_123",
- scopes: ["mcp"],
- resource: new URL("https://open-seo.test/mcp"),
- extra: { [MCP_AUTH_CONTEXT_PROP]: authContext },
- } satisfies AuthInfo,
-};
+const toolExtra = makeToolExtra();
describe("saved keyword MCP tools", () => {
beforeEach(() => {
- vi.resetModules();
- mocks.getProjectForOrganization.mockReset();
mocks.getProjectForOrganization.mockResolvedValue({
id: "project_1",
locationCode: 2840,
languageCode: "en",
});
- mocks.getSavedKeywords.mockReset();
- mocks.saveKeywords.mockReset();
});
it("passes tags through save_keywords", async () => {
@@ -65,7 +38,6 @@ describe("saved keyword MCP tools", () => {
success: true,
savedKeywordIds: ["saved_1"],
});
- const { saveKeywordsTool } = await import("./save-keywords");
const result = await saveKeywordsTool.handler(
{
@@ -96,7 +68,6 @@ describe("saved keyword MCP tools", () => {
success: true,
savedKeywordIds: ["saved_1", "saved_2"],
});
- const { saveKeywordsTool } = await import("./save-keywords");
const result = await saveKeywordsTool.handler(
{
@@ -124,8 +95,6 @@ describe("saved keyword MCP tools", () => {
});
it("rejects replace mode without replacement tags before saving", async () => {
- const { saveKeywordsTool } = await import("./save-keywords");
-
await expect(() =>
saveKeywordsTool.handler(
{
@@ -161,7 +130,6 @@ describe("saved keyword MCP tools", () => {
},
],
});
- const { listSavedKeywordsTool } = await import("./list-saved-keywords");
const result = await listSavedKeywordsTool.handler(
{
diff --git a/src/server/mcp/tools/search-console-tools.test.ts b/src/server/mcp/tools/search-console-tools.test.ts
index 7696ebb..a4569b1 100644
--- a/src/server/mcp/tools/search-console-tools.test.ts
+++ b/src/server/mcp/tools/search-console-tools.test.ts
@@ -1,41 +1,24 @@
-import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
-import type { ToolExtra } from "@/server/mcp/context";
-import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
+import { GscApiError, GscNotConnectedError } from "@/server/lib/gscErrors";
+import * as searchConsoleTools from "./search-console-tools";
+import { makeToolExtra } from "./tool-test-support";
const mocks = vi.hoisted(() => ({
getProjectForOrganization: vi.fn(),
isHostedServerAuthMode: vi.fn(),
- hasSelfHostedGscConfig: vi.fn(),
+ hasSelfHostedGoogleOAuthConfig: vi.fn(),
GscService: {
getPerformance: vi.fn(),
inspectUrls: vi.fn(),
},
}));
-class GscNotConnectedError extends Error {
- constructor(public readonly projectId: string) {
- super("not connected");
- this.name = "GscNotConnectedError";
- }
-}
-class GscApiError extends Error {
- constructor(
- public readonly status: number,
- message: string,
- ) {
- super(message);
- this.name = "GscApiError";
- }
-}
-class GscTokenError extends Error {}
-
vi.mock("cloudflare:workers", () => ({ env: {} }));
vi.mock("@/server/lib/runtime-env", () => ({
isHostedServerAuthMode: mocks.isHostedServerAuthMode,
}));
-vi.mock("@/server/features/gsc/oauth-config", () => ({
- hasSelfHostedGscConfig: mocks.hasSelfHostedGscConfig,
+vi.mock("@/server/features/google/oauth-config", () => ({
+ hasSelfHostedGoogleOAuthConfig: mocks.hasSelfHostedGoogleOAuthConfig,
}));
vi.mock("@/server/features/projects/services/ProjectService", () => ({
ProjectService: {
@@ -44,49 +27,18 @@ vi.mock("@/server/features/projects/services/ProjectService", () => ({
}));
vi.mock("@/server/features/gsc/services/GscService", () => ({
GscService: mocks.GscService,
- GscNotConnectedError,
}));
-vi.mock("@/server/lib/gscClient", () => ({ GscApiError, GscTokenError }));
-
-const authContext = {
- userId: "user_123",
- userEmail: "alice@example.com",
- organizationId: "org_123",
- clientId: "client_123",
- scopes: ["mcp"],
- audience: "https://open-seo.test/mcp",
- subject: "user_123",
- baseUrl: "https://open-seo.test",
-};
-
-const toolExtra: ToolExtra = {
- signal: new AbortController().signal,
- requestId: 1,
- sendNotification: vi.fn(),
- sendRequest: vi.fn(),
- authInfo: {
- token: "token",
- clientId: "client_123",
- scopes: ["mcp"],
- resource: new URL("https://open-seo.test/mcp"),
- extra: { [MCP_AUTH_CONTEXT_PROP]: authContext },
- } satisfies AuthInfo,
-};
+const toolExtra = makeToolExtra();
describe("search console MCP tools", () => {
beforeEach(() => {
- mocks.getProjectForOrganization.mockReset();
mocks.getProjectForOrganization.mockResolvedValue({
id: "project_1",
locationCode: 2840,
languageCode: "en",
});
- mocks.isHostedServerAuthMode.mockReset();
mocks.isHostedServerAuthMode.mockResolvedValue(true);
- mocks.hasSelfHostedGscConfig.mockReset();
- mocks.hasSelfHostedGscConfig.mockResolvedValue(false);
- mocks.GscService.getPerformance.mockReset();
- mocks.GscService.inspectUrls.mockReset();
+ mocks.hasSelfHostedGoogleOAuthConfig.mockResolvedValue(false);
});
it("returns performance rows on success and passes filters through", async () => {
@@ -109,8 +61,7 @@ describe("search console MCP tools", () => {
},
],
});
- const { getSearchConsolePerformanceTool } =
- await import("./search-console-tools");
+ const { getSearchConsolePerformanceTool } = searchConsoleTools;
const result = await getSearchConsolePerformanceTool.handler(
{
@@ -156,8 +107,7 @@ describe("search console MCP tools", () => {
mocks.GscService.getPerformance.mockRejectedValue(
new GscNotConnectedError("project_1"),
);
- const { getSearchConsolePerformanceTool } =
- await import("./search-console-tools");
+ const { getSearchConsolePerformanceTool } = searchConsoleTools;
const result = await getSearchConsolePerformanceTool.handler(
{ projectId: "project_1" },
@@ -179,8 +129,7 @@ describe("search console MCP tools", () => {
mocks.GscService.getPerformance.mockRejectedValue(
new GscApiError(403, "no access"),
);
- const { getSearchConsolePerformanceTool } =
- await import("./search-console-tools");
+ const { getSearchConsolePerformanceTool } = searchConsoleTools;
const result = await getSearchConsolePerformanceTool.handler(
{ projectId: "project_1" },
@@ -198,8 +147,7 @@ describe("search console MCP tools", () => {
});
it("rejects searchAppearance combined with another dimension", async () => {
- const { getSearchConsolePerformanceTool } =
- await import("./search-console-tools");
+ const { getSearchConsolePerformanceTool } = searchConsoleTools;
const result = await getSearchConsolePerformanceTool.handler(
{ projectId: "project_1", dimensions: ["query", "searchAppearance"] },
@@ -213,8 +161,7 @@ describe("search console MCP tools", () => {
});
it("rejects a half-specified explicit date range", async () => {
- const { getSearchConsolePerformanceTool } =
- await import("./search-console-tools");
+ const { getSearchConsolePerformanceTool } = searchConsoleTools;
const result = await getSearchConsolePerformanceTool.handler(
{ projectId: "project_1", startDate: "2026-01-01" },
@@ -229,9 +176,8 @@ describe("search console MCP tools", () => {
it("returns a setup message in self-hosted mode without a Google client", async () => {
mocks.isHostedServerAuthMode.mockResolvedValue(false);
- mocks.hasSelfHostedGscConfig.mockResolvedValue(false);
- const { getSearchConsolePerformanceTool } =
- await import("./search-console-tools");
+ mocks.hasSelfHostedGoogleOAuthConfig.mockResolvedValue(false);
+ const { getSearchConsolePerformanceTool } = searchConsoleTools;
const result = await getSearchConsolePerformanceTool.handler(
{ projectId: "project_1" },
@@ -246,7 +192,7 @@ describe("search console MCP tools", () => {
it("allows performance queries in self-hosted mode with a Google client", async () => {
mocks.isHostedServerAuthMode.mockResolvedValue(false);
- mocks.hasSelfHostedGscConfig.mockResolvedValue(true);
+ mocks.hasSelfHostedGoogleOAuthConfig.mockResolvedValue(true);
mocks.GscService.getPerformance.mockResolvedValue({
siteUrl: "https://example.com/",
connectedBy: "alice@example.com",
@@ -258,8 +204,7 @@ describe("search console MCP tools", () => {
},
rows: [],
});
- const { getSearchConsolePerformanceTool } =
- await import("./search-console-tools");
+ const { getSearchConsolePerformanceTool } = searchConsoleTools;
const result = await getSearchConsolePerformanceTool.handler(
{ projectId: "project_1" },
@@ -290,7 +235,7 @@ describe("search console MCP tools", () => {
},
],
});
- const { inspectUrlsTool } = await import("./search-console-tools");
+ const { inspectUrlsTool } = searchConsoleTools;
const result = await inspectUrlsTool.handler(
{
@@ -319,7 +264,7 @@ describe("search console MCP tools", () => {
mocks.GscService.inspectUrls.mockRejectedValue(
new GscNotConnectedError("project_1"),
);
- const { inspectUrlsTool } = await import("./search-console-tools");
+ const { inspectUrlsTool } = searchConsoleTools;
const result = await inspectUrlsTool.handler(
{ projectId: "project_1", urls: ["https://example.com/a"] },
@@ -334,8 +279,8 @@ describe("search console MCP tools", () => {
it("returns a setup message for inspect_urls in self-hosted mode without a Google client", async () => {
mocks.isHostedServerAuthMode.mockResolvedValue(false);
- mocks.hasSelfHostedGscConfig.mockResolvedValue(false);
- const { inspectUrlsTool } = await import("./search-console-tools");
+ mocks.hasSelfHostedGoogleOAuthConfig.mockResolvedValue(false);
+ const { inspectUrlsTool } = searchConsoleTools;
const result = await inspectUrlsTool.handler(
{ projectId: "project_1", urls: ["https://example.com/a"] },
diff --git a/src/server/mcp/tools/search-console-tools.ts b/src/server/mcp/tools/search-console-tools.ts
index b1a46d4..fa65138 100644
--- a/src/server/mcp/tools/search-console-tools.ts
+++ b/src/server/mcp/tools/search-console-tools.ts
@@ -7,12 +7,9 @@ import { withMcpProjectAuth } from "@/server/mcp/project-auth";
import { formatMcpTable, type McpTableColumn } from "@/server/mcp/table";
import { projectIdSchema } from "@/server/mcp/schemas";
import { buildDashboardUrl } from "@/server/mcp/urls";
-import { hasSelfHostedGscConfig } from "@/server/features/gsc/oauth-config";
+import { hasSelfHostedGoogleOAuthConfig } from "@/server/features/google/oauth-config";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
-import {
- GscNotConnectedError,
- GscService,
-} from "@/server/features/gsc/services/GscService";
+import { GscService } from "@/server/features/gsc/services/GscService";
import {
GSC_DATE_RANGES,
GSC_DEFAULT_ROW_LIMIT,
@@ -22,7 +19,11 @@ import {
GSC_SEARCH_TYPES,
type GscPerformanceInput,
} from "@/server/features/gsc/searchAnalytics";
-import { GscApiError, GscTokenError } from "@/server/lib/gscClient";
+import {
+ GscApiError,
+ GscNotConnectedError,
+ GscTokenError,
+} from "@/server/lib/gscErrors";
import { GSC_SELF_HOSTED_SETUP_DOCS_URL } from "@/shared/gsc";
const TEXT_SUMMARY_ROWS = 15;
@@ -72,7 +73,7 @@ async function missingSelfHostedGoogleClientResponse(
) {
const [hosted, configured] = await Promise.all([
isHostedServerAuthMode(),
- hasSelfHostedGscConfig(),
+ hasSelfHostedGoogleOAuthConfig(),
]);
if (hosted || configured) return null;
diff --git a/src/server/mcp/tools/tool-test-support.ts b/src/server/mcp/tools/tool-test-support.ts
new file mode 100644
index 0000000..aade8c4
--- /dev/null
+++ b/src/server/mcp/tools/tool-test-support.ts
@@ -0,0 +1,50 @@
+import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
+import { vi } from "vitest";
+import {
+ MCP_AUTH_CONTEXT_PROP,
+ type McpToolAuthContext,
+ type ToolExtra,
+} from "@/server/mcp/context";
+
+export function makeMcpAuthContext(
+ overrides: Partial = {},
+): McpToolAuthContext {
+ const baseUrl = overrides.baseUrl ?? "https://open-seo.test";
+ return {
+ userId: "user_123",
+ userEmail: "alice@example.com",
+ organizationId: "org_123",
+ clientId: "client_123",
+ scopes: ["mcp"],
+ audience: `${baseUrl}/mcp`,
+ subject: "user_123",
+ baseUrl,
+ ...overrides,
+ };
+}
+
+export function makeToolExtra(
+ authContext: McpToolAuthContext = makeMcpAuthContext(),
+ requestId: ToolExtra["requestId"] = 1,
+): ToolExtra {
+ return {
+ signal: new AbortController().signal,
+ requestId,
+ sendNotification: vi.fn(),
+ sendRequest: vi.fn(),
+ authInfo: {
+ token: "token",
+ clientId: authContext.clientId ?? "client_123",
+ scopes: authContext.scopes,
+ resource: new URL(`${authContext.baseUrl}/mcp`),
+ extra: { [MCP_AUTH_CONTEXT_PROP]: authContext },
+ } satisfies AuthInfo,
+ };
+}
+
+export function textContent(result: {
+ content?: Array<{ type: string; text?: string }>;
+}) {
+ const first = result.content?.[0];
+ return first?.type === "text" ? (first.text ?? "") : "";
+}
diff --git a/src/server/mcp/tools/tool-text-output.test.ts b/src/server/mcp/tools/tool-text-output.test.ts
index ce9ea97..5bf7254 100644
--- a/src/server/mcp/tools/tool-text-output.test.ts
+++ b/src/server/mcp/tools/tool-text-output.test.ts
@@ -1,7 +1,12 @@
-import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
-import type { ToolExtra } from "@/server/mcp/context";
-import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
+import * as researchTools from "./dataforseo-research-tools";
+import { getBacklinksOverviewTool } from "./get-backlinks-overview";
+import { getBacklinksProfileTool } from "./get-backlinks-profile";
+import { getDomainKeywordSuggestionsTool } from "./get-domain-keyword-suggestions";
+import { getRankTrackerTool } from "./get-rank-tracker";
+import { getSerpResultsTool } from "./get-serp-results";
+import { researchKeywordsTool } from "./research-keywords";
+import { makeToolExtra, textContent } from "./tool-test-support";
// Verifies that each tool renders its actual row data into the text content
// block (not just a count), across the tools whose data comes from OpenSEO
@@ -19,6 +24,8 @@ const mocks = vi.hoisted(() => ({
getConfigById: vi.fn(),
getConfigsForProject: vi.fn(),
getLatestResults: vi.fn(),
+ getTracker: vi.fn(),
+ getConfigs: vi.fn(),
}));
vi.mock("cloudflare:workers", () => ({ env: {} }));
@@ -55,41 +62,17 @@ vi.mock(
vi.mock("@/server/features/rank-tracking/services/rankTrackingResults", () => ({
getLatestResults: mocks.getLatestResults,
}));
+vi.mock("@/server/features/rank-tracking/services/RankTrackingService", () => ({
+ RankTrackingService: {
+ getTracker: mocks.getTracker,
+ getConfigs: mocks.getConfigs,
+ },
+}));
-const authContext = {
- userId: "user_123",
- userEmail: "alice@example.com",
- organizationId: "org_123",
- clientId: "client_123",
- scopes: ["mcp"],
- audience: "https://open-seo.test/mcp",
- subject: "user_123",
- baseUrl: "https://open-seo.test",
-};
-
-const toolExtra: ToolExtra = {
- signal: new AbortController().signal,
- requestId: 1,
- sendNotification: vi.fn(),
- sendRequest: vi.fn(),
- authInfo: {
- token: "token",
- clientId: "client_123",
- scopes: ["mcp"],
- resource: new URL("https://open-seo.test/mcp"),
- extra: { [MCP_AUTH_CONTEXT_PROP]: authContext },
- } satisfies AuthInfo,
-};
-
-function text(result: { content?: Array<{ type: string; text?: string }> }) {
- const first = result.content?.[0];
- return first?.type === "text" ? (first.text ?? "") : "";
-}
+const toolExtra = makeToolExtra();
describe("MCP tool text output (service-backed tools)", () => {
beforeEach(() => {
- vi.resetModules();
- for (const mock of Object.values(mocks)) mock.mockReset();
mocks.getProjectForOrganization.mockResolvedValue({
id: "project_1",
locationCode: 2840,
@@ -122,14 +105,13 @@ describe("MCP tool text output (service-backed tools)", () => {
source: "related",
usedFallback: false,
});
- const { researchKeywordsTool } = await import("./research-keywords");
const result = await researchKeywordsTool.handler(
{ projectId: "project_1", seeds: [{ seed: "seo tools" }] },
toolExtra,
);
- const out = text(result);
+ const out = textContent(result);
expect(out).toContain("keyword | volume | KD | CPC | competition | intent");
expect(out).toContain("seo tools | 2400 | 18 | 3.25 | 0.40 | commercial");
// Second row proves it isn't truncated and nulls render as em dashes.
@@ -145,15 +127,12 @@ describe("MCP tool text output (service-backed tools)", () => {
keywordDifficulty: 22,
},
]);
- const { getDomainKeywordSuggestionsTool } =
- await import("./get-domain-keyword-suggestions");
-
const result = await getDomainKeywordSuggestionsTool.handler(
{ projectId: "project_1", domain: "example.com" },
toolExtra,
);
- const out = text(result);
+ const out = textContent(result);
expect(out).toContain("keyword | position | volume | KD");
expect(out).toContain("seo audit | 4 | 880 | 22");
});
@@ -179,15 +158,12 @@ describe("MCP tool text output (service-backed tools)", () => {
},
],
});
- const { getBacklinksOverviewTool } =
- await import("./get-backlinks-overview");
-
const result = await getBacklinksOverviewTool.handler(
{ projectId: "project_1", target: "example.com" },
toolExtra,
);
- const out = text(result);
+ const out = textContent(result);
expect(out).toContain("domain | backlinks | referring pages | rank");
expect(out).toContain("linker.example | 42 | 5 | 30");
});
@@ -213,7 +189,6 @@ describe("MCP tool text output (service-backed tools)", () => {
totalCount: 1,
hasMore: false,
});
- const { getBacklinksProfileTool } = await import("./get-backlinks-profile");
const result = await getBacklinksProfileTool.handler(
{
@@ -229,7 +204,7 @@ describe("MCP tool text output (service-backed tools)", () => {
toolExtra,
);
- const out = text(result);
+ const out = textContent(result);
expect(out).toContain(
"source | target | anchor | type | rank | domainRank | spam | status",
);
@@ -239,37 +214,80 @@ describe("MCP tool text output (service-backed tools)", () => {
});
it("get_rank_tracker renders every tracked-keyword row (detail view)", async () => {
- mocks.getConfigById.mockResolvedValue({
- id: "tracker_1",
- domain: "example.com",
- scheduleInterval: "daily",
- devices: "desktop",
- serpDepth: 20,
+ mocks.getTracker.mockResolvedValue({
+ config: {
+ id: "tracker_1",
+ domain: "example.com",
+ scheduleInterval: "daily",
+ devices: "desktop",
+ serpDepth: 20,
+ },
+ results: {
+ run: { lastCheckedAt: "2026-07-01" },
+ rows: [
+ {
+ keyword: "seo tools",
+ desktop: { position: 3, previousPosition: 5 },
+ mobile: { position: 7, previousPosition: null },
+ },
+ ],
+ },
});
- mocks.getLatestResults.mockResolvedValue({
- run: { lastCheckedAt: "2026-07-01" },
- rows: [
- {
- keyword: "seo tools",
- desktop: { position: 3, previousPosition: 5 },
- mobile: { position: 7, previousPosition: null },
- },
- ],
- });
- const { getRankTrackerTool } = await import("./get-rank-tracker");
const result = await getRankTrackerTool.handler(
{ projectId: "project_1", trackerId: "tracker_1" },
toolExtra,
);
- const out = text(result);
+ const out = textContent(result);
expect(out).toContain(
"keyword | desktop | prev (desktop) | mobile | prev (mobile)",
);
expect(out).toContain("seo tools | 3 | 5 | 7 | —");
});
+ it("get_rank_tracker surfaces the latest run failure", async () => {
+ mocks.getTracker.mockResolvedValue({
+ config: {
+ id: "tracker_1",
+ domain: "example.com",
+ scheduleInterval: "daily",
+ devices: "desktop",
+ serpDepth: 20,
+ },
+ results: {
+ run: {
+ id: "run_1",
+ lastCheckedAt: null,
+ status: "failed",
+ errorMessage: "Provider request timed out",
+ },
+ rows: [],
+ },
+ });
+
+ const result = await getRankTrackerTool.handler(
+ { projectId: "project_1", trackerId: "tracker_1" },
+ toolExtra,
+ );
+
+ expect(textContent(result)).toContain(
+ "Latest run failed: Provider request timed out",
+ );
+ expect(result.structuredContent).toMatchObject({
+ results: {
+ run: {
+ status: "failed",
+ errorMessage: "Provider request timed out",
+ },
+ },
+ });
+ expect(
+ getRankTrackerTool.config.outputSchema.safeParse(result.structuredContent)
+ .success,
+ ).toBe(true);
+ });
+
it("get_ranked_keywords renders nested provider rows as a text table", async () => {
const rankedKeywords = vi.fn().mockResolvedValue({
items: [
@@ -288,15 +306,14 @@ describe("MCP tool text output (service-backed tools)", () => {
mocks.createDataforseoClient.mockReturnValue({
domain: { rankedKeywords },
});
- const { getRankedKeywordsTool } =
- await import("./dataforseo-research-tools");
+ const { getRankedKeywordsTool } = researchTools;
const result = await getRankedKeywordsTool.handler(
{ projectId: "project_1", target: "example.com" },
toolExtra,
);
- const out = text(result);
+ const out = textContent(result);
expect(out).toContain("keyword | rank | volume | CPC | url");
expect(out).toContain(
"seo tools | 4 | 1000 | 3.20 | https://example.com/tools",
@@ -315,14 +332,13 @@ describe("MCP tool text output (service-backed tools)", () => {
},
]);
mocks.createDataforseoClient.mockReturnValue({ serp: { live } });
- const { getSerpResultsTool } = await import("./get-serp-results");
const result = await getSerpResultsTool.handler(
{ projectId: "project_1", queries: [{ keyword: "seo tools" }] },
toolExtra,
);
- const out = text(result);
+ const out = textContent(result);
expect(out).toContain("rank | domain | title | url");
expect(out).toContain(
"1 | example.com | Best SEO Tools | https://example.com/best",
diff --git a/src/server/workflows/RankCheckWorkflow.test.ts b/src/server/workflows/RankCheckWorkflow.test.ts
new file mode 100644
index 0000000..e17b80d
--- /dev/null
+++ b/src/server/workflows/RankCheckWorkflow.test.ts
@@ -0,0 +1,163 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { WorkflowStep } from "cloudflare:workers";
+import {
+ prepareRankCheckKeywords,
+ RankCheckWorkflow,
+} from "./RankCheckWorkflow";
+
+const mocks = vi.hoisted(() => ({
+ getConfigById: vi.fn(),
+ getRunById: vi.fn(),
+ getKeywordsForConfig: vi.fn(),
+ updateRun: vi.fn(),
+ autumnCheck: vi.fn(),
+ createDataforseoClient: vi.fn(),
+ runLiveCheck: vi.fn(),
+ failRunIfActive: vi.fn(),
+ captureServerEvent: vi.fn(),
+ isHostedServerAuthMode: vi.fn(),
+}));
+
+vi.mock("cloudflare:workers", () => ({
+ WorkflowEntrypoint: vi.fn(),
+}));
+vi.mock("cloudflare:workflows", () => ({
+ NonRetryableError: class extends Error {},
+}));
+vi.mock("@/db", () => ({ withPgClient: (fn: () => unknown) => fn() }));
+vi.mock(
+ "@/server/features/rank-tracking/repositories/RankTrackingRepository",
+ () => ({ RankTrackingRepository: mocks }),
+);
+vi.mock("@/server/features/rank-tracking/services/rankCheckRunGuards", () => ({
+ failRunIfActive: mocks.failRunIfActive,
+}));
+vi.mock("@/server/workflows/rankCheckPaths", () => ({
+ runLiveCheck: mocks.runLiveCheck,
+ runQueuedCheck: vi.fn(),
+}));
+vi.mock("@/server/workflows/pgStep", () => ({
+ pgStep: (
+ _step: unknown,
+ _name: string,
+ _config: unknown,
+ fn: () => unknown,
+ ) => fn(),
+}));
+vi.mock("@/server/lib/dataforseo", () => ({
+ createDataforseoClient: mocks.createDataforseoClient,
+}));
+vi.mock("@/server/lib/posthog", () => ({
+ captureServerEvent: mocks.captureServerEvent,
+}));
+vi.mock("@/server/billing/autumn", () => ({
+ autumn: { check: mocks.autumnCheck },
+}));
+vi.mock("@/server/lib/runtime-env", () => ({
+ isHostedServerAuthMode: mocks.isHostedServerAuthMode,
+}));
+
+const billingCustomer = {
+ userId: "user_1",
+ userEmail: "user@example.com",
+ organizationId: "org_1",
+ projectId: "project_1",
+};
+
+const activeRun = {
+ id: "run_1",
+ status: "running",
+};
+
+describe("rank check workflow credit ceiling", () => {
+ beforeEach(() => {
+ mocks.getRunById.mockResolvedValue(activeRun);
+ mocks.updateRun.mockResolvedValue(undefined);
+ mocks.isHostedServerAuthMode.mockResolvedValue(true);
+ mocks.autumnCheck.mockResolvedValue({ balance: { remaining: 1_000 } });
+ });
+
+ it("rejects a keyword-list race before balance or DataForSEO calls", async () => {
+ mocks.getConfigById.mockResolvedValue({ isActive: true });
+ mocks.getKeywordsForConfig.mockResolvedValue(
+ Array.from({ length: 5 }, (_, index) => ({
+ id: `kw_${index}`,
+ keyword: `keyword ${index}`,
+ })),
+ );
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the mocked base class does not inspect Worker constructor context
+ const workflow = new RankCheckWorkflow({} as ExecutionContext, {} as Env);
+
+ await expect(
+ workflow.run(
+ {
+ instanceId: "run_1",
+ timestamp: new Date(),
+ payload: {
+ runId: "run_1",
+ configId: "config_1",
+ billingCustomer,
+ projectId: "project_1",
+ domain: "example.com",
+ locationCode: 2840,
+ languageCode: "en",
+ devices: "desktop",
+ serpDepth: 10,
+ trigger: "manual",
+ maxCostCredits: 12,
+ },
+ },
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- workflow steps are executed directly by the pgStep mock
+ {} as WorkflowStep,
+ ),
+ ).rejects.toMatchObject({ code: "VALIDATION_ERROR" });
+
+ expect(mocks.autumnCheck).not.toHaveBeenCalled();
+ expect(mocks.createDataforseoClient).not.toHaveBeenCalled();
+ expect(mocks.runLiveCheck).not.toHaveBeenCalled();
+ });
+
+ it("accepts the same reloaded list at the approved ceiling", async () => {
+ mocks.getKeywordsForConfig.mockResolvedValue(
+ Array.from({ length: 4 }, (_, index) => ({
+ id: `kw_${index}`,
+ keyword: `keyword ${index}`,
+ })),
+ );
+
+ const result = await prepareRankCheckKeywords({
+ runId: "run_1",
+ configId: "config_1",
+ billingCustomer,
+ devices: "desktop",
+ serpDepth: 10,
+ trigger: "manual",
+ maxCostCredits: 12,
+ });
+
+ expect(result.keywords).toHaveLength(4);
+ expect(mocks.autumnCheck).toHaveBeenCalledTimes(2);
+ });
+
+ it("preserves callers that do not provide a ceiling", async () => {
+ mocks.getKeywordsForConfig.mockResolvedValue(
+ Array.from({ length: 5 }, (_, index) => ({
+ id: `kw_${index}`,
+ keyword: `keyword ${index}`,
+ })),
+ );
+ mocks.isHostedServerAuthMode.mockResolvedValue(false);
+
+ const result = await prepareRankCheckKeywords({
+ runId: "run_1",
+ configId: "config_1",
+ billingCustomer,
+ devices: "desktop",
+ serpDepth: 10,
+ trigger: "manual",
+ });
+
+ expect(result.keywords).toHaveLength(5);
+ expect(mocks.autumnCheck).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/server/workflows/RankCheckWorkflow.ts b/src/server/workflows/RankCheckWorkflow.ts
index 556909d..f056ee7 100644
--- a/src/server/workflows/RankCheckWorkflow.ts
+++ b/src/server/workflows/RankCheckWorkflow.ts
@@ -22,7 +22,10 @@ import {
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
} from "@/shared/billing";
-import { estimateRankCheckCredits } from "@/shared/rank-tracking";
+import {
+ estimateRankCheckCredits,
+ rankCheckCostApprovalError,
+} from "@/shared/rank-tracking";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
const SINGLE_ATTEMPT_STEP_CONFIG = {
@@ -43,9 +46,10 @@ interface RankCheckParams {
serpDepth: number;
trigger: "manual" | "scheduled";
keywordIds?: string[];
+ maxCostCredits?: number;
}
-async function prepareRankCheckKeywords(input: {
+export async function prepareRankCheckKeywords(input: {
runId: string;
configId: string;
billingCustomer: BillingCustomerContext;
@@ -53,6 +57,7 @@ async function prepareRankCheckKeywords(input: {
serpDepth: number;
trigger: RankCheckParams["trigger"];
keywordIds?: string[];
+ maxCostCredits?: number;
}) {
// If stale-cleanup marked our run failed before we got here, bail out
// rather than resurrecting a superseded run.
@@ -80,16 +85,23 @@ async function prepareRankCheckKeywords(input: {
throw new AppError("INTERNAL_ERROR", "No keywords to track");
}
+ const { costCredits } = estimateRankCheckCredits(
+ trackingKeywords.length,
+ input.devices,
+ input.serpDepth,
+ input.trigger === "scheduled" ? "queued" : "live",
+ );
+ if (input.maxCostCredits != null && costCredits > input.maxCostCredits) {
+ throw new AppError(
+ "VALIDATION_ERROR",
+ rankCheckCostApprovalError(costCredits, input.maxCostCredits),
+ );
+ }
+
// Verify the user has enough credits for the full check before starting.
// Scheduled checks go through the cheaper task queue, so estimate at queued
// pricing — a live-price estimate would skip checks the user can afford.
if (await isHostedServerAuthMode()) {
- const { costCredits } = estimateRankCheckCredits(
- trackingKeywords.length,
- input.devices,
- input.serpDepth,
- input.trigger === "scheduled" ? "queued" : "live",
- );
const [monthlyCheck, topupCheck] = await Promise.all([
autumn.check({
customerId: input.billingCustomer.organizationId,
@@ -275,10 +287,9 @@ export class RankCheckWorkflow extends WorkflowEntrypoint<
serpDepth,
trigger,
keywordIds,
+ maxCostCredits,
} = event.payload;
- const client = createDataforseoClient(billingCustomer);
-
// Guard: skip if config was archived after the workflow was triggered
const configCheck = await pgStep(
step,
@@ -315,10 +326,12 @@ export class RankCheckWorkflow extends WorkflowEntrypoint<
serpDepth,
trigger,
keywordIds,
+ maxCostCredits,
}),
);
const keywords = prepareResult.keywords;
+ const client = createDataforseoClient(billingCustomer);
console.log(`[rank-check] ${runId} loaded ${keywords.length} keywords`);
diff --git a/src/serverFunctions/dashboard.ts b/src/serverFunctions/dashboard.ts
index ef9b5cf..fc8adec 100644
--- a/src/serverFunctions/dashboard.ts
+++ b/src/serverFunctions/dashboard.ts
@@ -61,3 +61,14 @@ export const dismissDashboardMcpCard = createServerFn({ method: "POST" })
await ActivationRepository.markMcpCardDismissed(context.projectId);
return { ok: true as const };
});
+
+// Hides only the optional GA4 pitch on this project's dashboard. The
+// integration remains available in Project Settings and a later connection
+// makes the dashboard card visible again.
+export const dismissDashboardGa4Card = createServerFn({ method: "POST" })
+ .middleware(requireProjectContext)
+ .validator(dashboardProjectInputSchema)
+ .handler(async ({ context }) => {
+ await ActivationRepository.markGa4CardDismissed(context.projectId);
+ return { ok: true as const };
+ });
diff --git a/src/serverFunctions/ga4.ts b/src/serverFunctions/ga4.ts
new file mode 100644
index 0000000..d2a04d8
--- /dev/null
+++ b/src/serverFunctions/ga4.ts
@@ -0,0 +1,131 @@
+import { createServerFn } from "@tanstack/react-start";
+import { getRequest } from "@tanstack/react-start/server";
+import { waitUntil } from "cloudflare:workers";
+import { z } from "zod";
+import { Ga4Service } from "@/server/features/ga4/services/Ga4Service";
+import { hasSelfHostedGoogleOAuthConfig } from "@/server/features/google/oauth-config";
+import {
+ createSelfHostedGoogleAuthorizationUrl,
+ GA4_INTEGRATION,
+} from "@/server/features/google/selfHostedOAuth";
+import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
+import { captureServerEvent } from "@/server/lib/posthog";
+import { getPublicOrigin } from "@/server/mcp/public-origin";
+import {
+ requireAuthenticatedContext,
+ requireProjectContext,
+} from "@/serverFunctions/middleware";
+
+const projectScopedSchema = z.object({ projectId: z.string().min(1) });
+const setPropertySchema = projectScopedSchema.extend({
+ accountId: z.string().min(1),
+ propertyId: z.string().regex(/^properties\/\d+$/),
+});
+const startSelfHostedLinkSchema = z.object({
+ callbackURL: z.string().min(1),
+});
+
+export const getGa4Connection = createServerFn({ method: "POST" })
+ .middleware(requireProjectContext)
+ .validator(projectScopedSchema)
+ .handler(async ({ context }) => {
+ const [connection, currentUserHasGrant, hosted, ga4Configured] =
+ await Promise.all([
+ Ga4Service.getConnection(context.projectId),
+ Ga4Service.userHasGrant(context.userId),
+ isHostedServerAuthMode(),
+ hasSelfHostedGoogleOAuthConfig(),
+ ]);
+ return {
+ connected: Boolean(connection),
+ currentUserHasGrant,
+ googleOAuthConfigured: hosted || ga4Configured,
+ propertyId: connection?.propertyId ?? null,
+ propertyDisplayName: connection?.propertyDisplayName ?? null,
+ propertyTimeZone: connection?.propertyTimeZone ?? null,
+ propertyCurrencyCode: connection?.propertyCurrencyCode ?? null,
+ connectedByEmail: connection?.connectedAccountEmail ?? null,
+ connectedAt: connection?.createdAt ?? null,
+ };
+ });
+
+export const listGa4Properties = createServerFn({ method: "POST" })
+ .middleware(requireProjectContext)
+ .validator(projectScopedSchema)
+ .handler(async ({ context }) => {
+ const [propertyList, connection] = await Promise.all([
+ Ga4Service.listPropertiesForUserWithGrantStatus(context.userId),
+ Ga4Service.getConnection(context.projectId),
+ ]);
+ return {
+ accounts: propertyList.accounts.map((grant) => ({
+ ...grant,
+ properties: grant.properties.map((property) => ({
+ ...property,
+ isSelected:
+ connection?.ga4AccountId === grant.accountId &&
+ connection.propertyId === property.propertyId,
+ })),
+ })),
+ };
+ });
+
+export const setGa4Property = createServerFn({ method: "POST" })
+ .middleware(requireProjectContext)
+ .validator(setPropertySchema)
+ .handler(async ({ data, context }) => {
+ const connection = await Ga4Service.setProperty({
+ projectId: context.projectId,
+ organizationId: context.organizationId,
+ accountId: data.accountId,
+ propertyId: data.propertyId,
+ userId: context.userId,
+ });
+ waitUntil(
+ captureServerEvent({
+ distinctId: context.userId,
+ event: "ga4:property_select",
+ organizationId: context.organizationId,
+ properties: { project_id: context.projectId },
+ }),
+ );
+ return {
+ connected: true as const,
+ propertyId: connection.propertyId,
+ propertyDisplayName: connection.propertyDisplayName,
+ };
+ });
+
+export const disconnectGa4 = createServerFn({ method: "POST" })
+ .middleware(requireProjectContext)
+ .validator(projectScopedSchema)
+ .handler(async ({ context }) => {
+ await Ga4Service.disconnect({
+ projectId: context.projectId,
+ userId: context.userId,
+ });
+ waitUntil(
+ captureServerEvent({
+ distinctId: context.userId,
+ event: "ga4:disconnect",
+ organizationId: context.organizationId,
+ properties: { project_id: context.projectId },
+ }),
+ );
+ return { connected: false as const };
+ });
+
+export const startSelfHostedGa4Link = createServerFn({ method: "POST" })
+ .middleware(requireAuthenticatedContext)
+ .validator(startSelfHostedLinkSchema)
+ .handler(async ({ data, context }) => ({
+ url: await createSelfHostedGoogleAuthorizationUrl({
+ integration: GA4_INTEGRATION,
+ user: {
+ userId: context.userId,
+ userEmail: context.userEmail,
+ },
+ callbackURL: data.callbackURL,
+ publicOrigin: getPublicOrigin(getRequest()),
+ }),
+ }));
diff --git a/src/serverFunctions/gsc.ts b/src/serverFunctions/gsc.ts
index 46b0f6a..ce43ffb 100644
--- a/src/serverFunctions/gsc.ts
+++ b/src/serverFunctions/gsc.ts
@@ -3,8 +3,11 @@ import { getRequest } from "@tanstack/react-start/server";
import { waitUntil } from "cloudflare:workers";
import { z } from "zod";
import { GscService } from "@/server/features/gsc/services/GscService";
-import { hasSelfHostedGscConfig } from "@/server/features/gsc/oauth-config";
-import { createSelfHostedGscAuthorizationUrl } from "@/server/features/gsc/selfHostedOAuth";
+import { hasSelfHostedGoogleOAuthConfig } from "@/server/features/google/oauth-config";
+import {
+ createSelfHostedGoogleAuthorizationUrl,
+ GSC_INTEGRATION,
+} from "@/server/features/google/selfHostedOAuth";
import { captureServerEvent } from "@/server/lib/posthog";
import { getPublicOrigin } from "@/server/mcp/public-origin";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
@@ -40,7 +43,7 @@ export const getGscConnection = createServerFn({ method: "POST" })
GscService.getConnection(context.projectId),
GscService.userHasGrant(context.userId),
isHostedServerAuthMode(),
- hasSelfHostedGscConfig(),
+ hasSelfHostedGoogleOAuthConfig(),
]);
return {
connected: Boolean(connection),
@@ -131,7 +134,8 @@ export const startSelfHostedGscLink = createServerFn({ method: "POST" })
.validator(startSelfHostedLinkSchema)
.handler(async ({ data, context }) => {
const publicOrigin = getPublicOrigin(getRequest());
- const url = await createSelfHostedGscAuthorizationUrl({
+ const url = await createSelfHostedGoogleAuthorizationUrl({
+ integration: GSC_INTEGRATION,
user: {
userId: context.userId,
userEmail: context.userEmail,
diff --git a/src/serverFunctions/rank-tracking.ts b/src/serverFunctions/rank-tracking.ts
index 8f399a9..4174edf 100644
--- a/src/serverFunctions/rank-tracking.ts
+++ b/src/serverFunctions/rank-tracking.ts
@@ -4,8 +4,6 @@ import { RankTrackingRepository } from "@/server/features/rank-tracking/reposito
import { RankTrackingService } from "@/server/features/rank-tracking/services/RankTrackingService";
import { getLatestResults } from "@/server/features/rank-tracking/services/rankTrackingResults";
import { AppError, asAppError } from "@/server/lib/errors";
-import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
-import { customerHasPaidPlan } from "@/server/billing/subscription";
import { captureServerEvent } from "@/server/lib/posthog";
import { requireProjectContext } from "@/serverFunctions/middleware";
import {
@@ -125,14 +123,6 @@ export const triggerRankCheck = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.validator(triggerCheckSchema)
.handler(async ({ data, context }) => {
- const isHosted = await isHostedServerAuthMode();
- if (isHosted && !(await customerHasPaidPlan(context.organizationId))) {
- throw new AppError(
- "PAYMENT_REQUIRED",
- "Upgrade to the paid plan to run rank checks",
- );
- }
-
const result = await RankTrackingService.triggerCheck({
configId: data.configId,
projectId: context.projectId,
@@ -183,6 +173,17 @@ export const estimateRankCheckCost = createServerFn({ method: "POST" })
return RankTrackingService.estimateCost(data.configId, context.projectId);
});
+function logAutoActionFailure(action: string, err: unknown) {
+ const appErr = asAppError(err);
+ if (appErr?.code === "PAYMENT_REQUIRED") {
+ console.info(`[rank-tracking] ${action} skipped: paid plan required`);
+ } else if (appErr?.code === "INSUFFICIENT_CREDITS") {
+ console.info(`[rank-tracking] ${action} skipped: insufficient credits`);
+ } else {
+ console.error(`[rank-tracking] ${action} failed:`, err);
+ }
+}
+
export const addTrackingKeywords = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.validator(addKeywordsSchema)
@@ -191,42 +192,27 @@ export const addTrackingKeywords = createServerFn({ method: "POST" })
data.configId,
context.projectId,
data.keywords,
+ { kind: "direct_user_action" },
);
let checkTriggered = false;
if (result.addedIds.length > 0) {
- const isHosted = await isHostedServerAuthMode();
- const hasPaidPlan =
- !isHosted || (await customerHasPaidPlan(context.organizationId));
-
- if (hasPaidPlan) {
- try {
- const triggerResult = await RankTrackingService.triggerCheck({
- configId: data.configId,
- projectId: context.projectId,
- billingCustomer: context,
- keywordIds: result.addedIds,
- });
- checkTriggered = triggerResult.ok;
- if (!triggerResult.ok) {
- console.info(
- "[rank-tracking] auto-check skipped: %s",
- triggerResult.reason,
- );
- }
- } catch (err) {
- const appErr = asAppError(err);
- if (appErr?.code === "INSUFFICIENT_CREDITS") {
- console.info(
- "[rank-tracking] auto-check skipped: insufficient credits",
- );
- } else {
- console.error(
- "[rank-tracking] auto-check after keyword add failed:",
- err,
- );
- }
+ try {
+ const triggerResult = await RankTrackingService.triggerCheck({
+ configId: data.configId,
+ projectId: context.projectId,
+ billingCustomer: context,
+ keywordIds: result.addedIds,
+ });
+ checkTriggered = triggerResult.ok;
+ if (!triggerResult.ok) {
+ console.info(
+ "[rank-tracking] auto-check skipped: %s",
+ triggerResult.reason,
+ );
}
+ } catch (err) {
+ logAutoActionFailure("auto-check", err);
}
}
@@ -239,14 +225,7 @@ export const addTrackingKeywords = createServerFn({ method: "POST" })
context,
);
} catch (err) {
- const appErr = asAppError(err);
- if (appErr?.code === "INSUFFICIENT_CREDITS") {
- console.info(
- "[rank-tracking] auto-metrics-refresh skipped: insufficient credits",
- );
- } else {
- console.error("[rank-tracking] auto-metrics-refresh failed:", err);
- }
+ logAutoActionFailure("auto-metrics-refresh", err);
}
}
@@ -257,12 +236,11 @@ export const removeTrackingKeywords = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.validator(removeKeywordsSchema)
.handler(async ({ data, context }) => {
- await RankTrackingService.removeKeywords(
+ return RankTrackingService.removeKeywords(
data.configId,
context.projectId,
data.keywordIds,
);
- return { removed: data.keywordIds.length };
});
export const refreshTrackingKeywordMetrics = createServerFn({ method: "POST" })
diff --git a/src/shared/ga4.ts b/src/shared/ga4.ts
new file mode 100644
index 0000000..2044762
--- /dev/null
+++ b/src/shared/ga4.ts
@@ -0,0 +1,12 @@
+/** Better Auth provider ID for the dedicated Google Analytics grant. */
+export const GA4_OAUTH_PROVIDER_ID = "google-analytics";
+
+export const GA4_OAUTH_SCOPES = [
+ "openid",
+ "email",
+ "profile",
+ "https://www.googleapis.com/auth/analytics.readonly",
+] as const;
+
+export const GA4_SELF_HOSTED_SETUP_DOCS_URL =
+ "https://github.com/every-app/open-seo/blob/main/docs/SELF_HOSTING_GOOGLE_ANALYTICS.md";
diff --git a/src/shared/rank-tracking.test.ts b/src/shared/rank-tracking.test.ts
index b89f7df..b90b8c3 100644
--- a/src/shared/rank-tracking.test.ts
+++ b/src/shared/rank-tracking.test.ts
@@ -1,5 +1,53 @@
import { afterEach, describe, expect, it, vi } from "vitest";
-import { computeNextCheckAt, scheduleLabel } from "./rank-tracking";
+import {
+ computeNextCheckAt,
+ estimateRankCheckCredits,
+ scheduleLabel,
+} from "./rank-tracking";
+
+describe("rank tracking cost estimates", () => {
+ it.each([
+ {
+ method: "live" as const,
+ keywordCount: 4,
+ devices: "desktop" as const,
+ depth: 10,
+ costUsd: 0.01024,
+ costCredits: 12,
+ },
+ {
+ method: "live" as const,
+ keywordCount: 1000,
+ devices: "both" as const,
+ depth: 40,
+ costUsd: 16.64,
+ costCredits: 18_000,
+ },
+ {
+ method: "queued" as const,
+ keywordCount: 104,
+ devices: "desktop" as const,
+ depth: 10,
+ costUsd: 0.07987,
+ costCredits: 81,
+ },
+ {
+ method: "queued" as const,
+ keywordCount: 1000,
+ devices: "both" as const,
+ depth: 40,
+ costUsd: 4.992,
+ costCredits: 5_000,
+ },
+ ])(
+ "matches per-call billing for $method checks",
+ ({ keywordCount, devices, depth, method, costUsd, costCredits }) => {
+ expect(
+ estimateRankCheckCredits(keywordCount, devices, depth, method),
+ ).toEqual({ costUsd, costCredits });
+ },
+ );
+});
describe("rank tracking schedules", () => {
afterEach(() => {
diff --git a/src/shared/rank-tracking.ts b/src/shared/rank-tracking.ts
index 0577fe2..b673d13 100644
--- a/src/shared/rank-tracking.ts
+++ b/src/shared/rank-tracking.ts
@@ -33,7 +33,7 @@ export const KEYWORDS_PER_BATCH = 10;
/** Approximate seconds per batch */
export const SECONDS_PER_BATCH = 6;
-/** Maximum keywords allowed per rank tracking config */
+/** Soft application limit for keywords per rank tracking config */
export const MAX_KEYWORDS_PER_CONFIG = 1000;
/** Maximum length of a single tracked keyword */
@@ -42,6 +42,16 @@ export const MAX_TRACKED_KEYWORD_LENGTH = 200;
/** Maximum configs (domain+location combos) per project */
export const MAX_CONFIGS_PER_PROJECT = 500;
+/** Maximum queued rank-check tasks DataForSEO accepts in one task_post. */
+export const MAX_TASKS_PER_POST = 100;
+
+export const rankCheckCostApprovalError = (
+ costCredits: number,
+ maxCostCredits: number,
+) => {
+ return `The current rank check costs ${costCredits} credits, above the approved maximum of ${maxCostCredits}. Call estimate_rank_tracker_cost again and ask the user to approve the updated amount.`;
+};
+
// ---------------------------------------------------------------------------
// Cost estimation
// ---------------------------------------------------------------------------
@@ -69,10 +79,26 @@ export function estimateRankCheckCredits(
method: RankCheckMethod,
) {
const totalChecks = keywordCount * devicesCount(devices);
- const costUsd = roundUsdForBilling(
- totalChecks * costPerSerpAtDepth(depth, method) * SEO_DATA_COST_MARKUP,
- );
- const costCredits = Math.ceil(costUsd * AUTUMN_SEO_DATA_CREDITS_PER_USD);
+ const checksPerMeteredCall = method === "queued" ? MAX_TASKS_PER_POST : 1;
+ let costUsd = 0;
+ let costCredits = 0;
+
+ // Metering rounds and ceilings each provider call independently. Live rank
+ // checks make one call per keyword/device pair, while queued checks post up
+ // to MAX_TASKS_PER_POST pairs per call. Summing one aggregate and rounding
+ // once can therefore understate the credits that will actually be charged.
+ for (let offset = 0; offset < totalChecks; offset += checksPerMeteredCall) {
+ const checksInCall = Math.min(checksPerMeteredCall, totalChecks - offset);
+ const callCostUsd = roundUsdForBilling(
+ checksInCall * costPerSerpAtDepth(depth, method) * SEO_DATA_COST_MARKUP,
+ );
+ costUsd += callCostUsd;
+ costCredits += Math.ceil(callCostUsd * AUTUMN_SEO_DATA_CREDITS_PER_USD);
+ }
+
+ // This is the nominal queued task_post estimate. Rejected, failed, or
+ // timed-out tasks can later incur additional live-fallback spend.
+ costUsd = roundUsdForBilling(costUsd);
return { costUsd, costCredits };
}
@@ -85,6 +111,30 @@ type ScheduledRankTrackingInterval = Exclude<
"manual"
>;
+export function estimateScheduledRankCheckCredits(
+ keywordCount: number,
+ devices: RankTrackingConfig["devices"],
+ depth: number,
+ scheduleInterval: ScheduledRankTrackingInterval,
+) {
+ const { costUsd, costCredits } = estimateRankCheckCredits(
+ keywordCount,
+ devices,
+ depth,
+ "queued",
+ );
+ const checksPerMonth =
+ scheduleInterval === "daily" ? 30 : scheduleInterval === "weekly" ? 4 : 1;
+ return {
+ scheduleInterval,
+ costUsd,
+ costCredits,
+ checksPerMonth,
+ monthlyCostUsd: costUsd * checksPerMonth,
+ monthlyCostCredits: costCredits * checksPerMonth,
+ };
+}
+
export function isScheduledRankTrackingInterval(
interval: RankTrackingConfig["scheduleInterval"],
): interval is ScheduledRankTrackingInterval {