feat: add GA4 MCP insights and rank tracking management (#461)

This commit is contained in:
Ben Senescu 2026-08-07 13:47:18 -04:00 committed by GitHub
parent c40a04459c
commit e2c84803f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
112 changed files with 17337 additions and 1227 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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");

File diff suppressed because it is too large Load Diff

View File

@ -120,6 +120,13 @@
"when": 1785608388841,
"tag": "0016_panoramic_blob",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1786066279773,
"tag": "0017_ga4_connections",
"breakpoints": true
}
]
}

View File

@ -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;

File diff suppressed because it is too large Load Diff

View File

@ -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
}
]
}

View File

@ -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)

View File

@ -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() {

View File

@ -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 (
<div className="px-4 py-4 pb-24 md:px-6 md:py-6 md:pb-8">
@ -330,6 +332,20 @@ export function DashboardPage({ projectId }: { projectId: string }) {
hasData: gscConnected,
node: <GscCard projectId={projectId} connected={gscConnected} />,
},
...(ga4Connected || !activation.ga4.cardDismissedAt
? [
{
key: "ga4",
hasData: ga4Connected,
node: (
<Ga4ConnectCard
projectId={projectId}
connected={ga4Connected}
/>
),
},
]
: []),
{
key: "audit",
hasData: overview?.audit != null,

View File

@ -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 (
<GoogleAnalyticsConnectionCard
projectId={projectId}
onDismiss={
connected
? undefined
: () => {
captureClientEvent("dashboard:ga4_dismiss");
dismissMutation.mutate();
}
}
dismissing={dismissMutation.isPending}
/>
);
}

View File

@ -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 (
<div className="flex items-center gap-2 text-sm text-base-content/50">
<span className="loading loading-spinner loading-sm" />
Loading properties
</div>
);
}
if (error) {
return (
<div className="space-y-3">
<p className="text-sm text-error">
Couldn&rsquo;t load your Google Analytics properties.
</p>
<div className="flex flex-wrap items-center gap-1">
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={onRetry}
>
Try again
</button>
{secondaryAction ? (
<SecondaryActionButton action={secondaryAction} />
) : null}
</div>
</div>
);
}
const allAccountsRequireReconnect =
accounts.length > 0 &&
accounts.every((account) => account.requiresReconnect);
if (allAccountsRequireReconnect) {
return (
<div className="space-y-3">
<p className="text-sm text-error">
Connection expired. Reconnect to continue.
</p>
<div className="flex flex-wrap items-center gap-1">
<GoogleConnectButton
label="Reconnect with Google"
onClick={() => void startGoogleLink("ga4", window.location.href)}
/>
{secondaryAction ? (
<SecondaryActionButton action={secondaryAction} />
) : null}
</div>
</div>
);
}
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 (
<div className="space-y-4">
{hasUnavailableAccounts ? (
<p className="text-sm text-warning">
Some properties couldn&rsquo;t be loaded. Check that the Analytics
Admin API is enabled and that this Google account has property access.
</p>
) : null}
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-base-content/80">
Property
</span>
<select
className="select select-bordered w-full max-w-md"
value={selectedIndex >= 0 ? String(selectedIndex) : ""}
onChange={(event) => {
const option = options[Number(event.target.value)];
if (option) onSelect(option);
}}
>
<option value="" disabled>
Select a property
</option>
{usableAccounts.map((account) => (
<optgroup
key={account.accountId}
label={account.email ?? "Google account"}
>
{account.properties.length === 0 ? (
<option disabled>No properties</option>
) : (
account.properties.map((property) => {
const index = options.findIndex(
(option) =>
option.accountId === account.accountId &&
option.propertyId === property.propertyId,
);
return (
<option key={property.propertyId} value={index}>
{property.accountDisplayName} · {property.displayName}
</option>
);
})
)}
</optgroup>
))}
</select>
</label>
{options.length === 0 && !hasUnavailableAccounts ? (
<p className="text-sm text-base-content/60">
No Google Analytics properties are available for this account.
</p>
) : null}
<div className="flex flex-wrap items-center gap-1">
<button
type="button"
className="btn btn-primary btn-sm"
onClick={onSave}
disabled={selectedIndex < 0 || saving}
>
{saving ? "Saving…" : "Save property"}
</button>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => void startGoogleLink("ga4", window.location.href)}
>
Connect another Google account
</button>
{secondaryAction ? (
<SecondaryActionButton action={secondaryAction} />
) : null}
</div>
</div>
);
}
function SecondaryActionButton({ action }: { action: SecondaryAction }) {
return (
<button
type="button"
className={[
"btn btn-ghost btn-sm",
action.destructive ? "text-error hover:bg-error/10" : "",
].join(" ")}
onClick={action.onClick}
disabled={action.disabled}
>
{action.label}
</button>
);
}
function GoogleConnectButton({
label,
onClick,
}: {
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className="inline-flex items-center gap-2.5 rounded-lg border border-base-300 bg-base-100 px-4 py-2.5 text-sm font-semibold shadow-sm transition hover:bg-base-200"
>
<GoogleGlyph className="size-[18px]" />
{label}
</button>
);
}

View File

@ -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<Ga4PropertySelection | null>(
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 (
<IntegrationConnectionCard
title="Google Analytics"
icon={<GoogleAnalyticsLogo className="size-5" />}
status={
connectionQuery.isLoading
? undefined
: selfHostedNeedsSetup
? "setup_required"
: connected
? "connected"
: "disconnected"
}
>
{connectionQuery.isLoading ? (
<div className="flex items-center gap-2 text-sm text-base-content/50">
<span className="loading loading-spinner loading-sm" />
Checking
</div>
) : selfHostedNeedsSetup ? (
<div className="space-y-3">
<GoogleOAuthSetupWarning
integrationName="Google Analytics"
docsUrl={GA4_SELF_HOSTED_SETUP_DOCS_URL}
/>
{onDismiss ? (
<DismissButton onClick={onDismiss} disabled={dismissing} />
) : null}
</div>
) : connected && !picking ? (
<ConnectedState
displayName={connection?.propertyDisplayName ?? ""}
propertyId={connection?.propertyId ?? ""}
timeZone={connection?.propertyTimeZone ?? ""}
currencyCode={connection?.propertyCurrencyCode ?? ""}
connectedByEmail={connection?.connectedByEmail ?? null}
onChange={() => {
setSelection(null);
setPicking(true);
}}
onDisconnect={() => disconnectMutation.mutate()}
disconnecting={disconnectMutation.isPending}
/>
) : showPicker ? (
<Ga4PropertyPicker
loading={propertiesQuery.isLoading}
error={propertiesQuery.isError}
accounts={accounts}
selection={selection}
onSelect={setSelection}
onSave={() => 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(),
}
}
/>
) : (
<div className="space-y-4">
<p className="text-sm text-base-content/70">
Connect GA4 to understand what organic visitors do after they land
on your site.
</p>
<div className="flex flex-wrap items-center gap-1">
<button
type="button"
onClick={handleConnect}
className="inline-flex items-center gap-2.5 rounded-lg border border-base-300 bg-base-100 px-4 py-2.5 text-sm font-semibold text-base-content shadow-sm transition hover:bg-base-200 hover:shadow focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
>
<GoogleGlyph className="size-[18px]" />
Connect with Google
</button>
{onDismiss ? (
<DismissButton onClick={onDismiss} disabled={dismissing} />
) : null}
</div>
</div>
)}
</IntegrationConnectionCard>
);
}
function DismissButton({
onClick,
disabled,
}: {
onClick: () => void;
disabled: boolean;
}) {
return (
<button
type="button"
className="btn btn-ghost btn-sm text-base-content/60"
onClick={onClick}
disabled={disabled}
>
Dismiss
</button>
);
}
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 (
<div className="space-y-3">
<div className="rounded-lg border border-base-300 bg-base-200/30 px-4 py-3.5">
<div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-2">
<div className="min-w-0">
<p className="text-[11px] font-medium uppercase tracking-wide text-base-content/45">
Selected property
</p>
<p className="mt-0.5 truncate text-sm font-semibold">
{displayName}
</p>
</div>
<span className="rounded-md border border-base-300 bg-base-100 px-2 py-1 font-mono text-[11px] text-base-content/60">
ID {numericPropertyId}
</span>
</div>
<dl className="mt-3 grid gap-x-6 gap-y-2 border-t border-base-300/70 pt-3 text-xs sm:grid-cols-3">
<div className="min-w-0">
<dt className="text-base-content/45">Time zone</dt>
<dd className="mt-0.5 truncate font-medium text-base-content/75">
{timeZone}
</dd>
</div>
<div>
<dt className="text-base-content/45">Currency</dt>
<dd className="mt-0.5 font-medium text-base-content/75">
{currencyCode}
</dd>
</div>
{connectedByEmail ? (
<div className="min-w-0">
<dt className="text-base-content/45">Connected account</dt>
<dd className="mt-0.5 truncate font-medium text-base-content/75">
{connectedByEmail}
</dd>
</div>
) : null}
</dl>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className="btn btn-outline btn-sm border-base-300 font-medium"
onClick={onChange}
>
Change property
</button>
<button
type="button"
className="btn btn-ghost btn-sm font-medium text-error hover:bg-error/10"
onClick={onDisconnect}
disabled={disconnecting}
>
{disconnecting ? "Disconnecting…" : "Disconnect"}
</button>
</div>
</div>
);
}

View File

@ -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 (

View File

@ -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 (
<IntegrationCard
<IntegrationConnectionCard
title="Google Search Console"
icon={<GoogleSearchConsoleLogo className="size-5" />}
status={
connectionQuery.isLoading
? undefined
@ -204,68 +208,7 @@ export function SearchConsoleConnectionCard({
</button>
</div>
)}
</IntegrationCard>
);
}
// ---------------------------------------------------------------------------
// Card shell
// ---------------------------------------------------------------------------
function IntegrationCard({
status,
children,
}: {
status?: "connected" | "disconnected" | "setup_required";
children: React.ReactNode;
}) {
return (
<div className="overflow-hidden rounded-xl border border-base-300 bg-base-100 shadow-sm">
<div className="flex items-start justify-between gap-4 p-5 sm:p-6">
<h2 className="text-base font-semibold leading-tight">
Google Search Console
</h2>
{status ? <StatusPill status={status} /> : null}
</div>
<div className="border-t border-base-300 p-5 sm:p-6">{children}</div>
</div>
);
}
function StatusPill({
status,
}: {
status: "connected" | "disconnected" | "setup_required";
}) {
const connected = status === "connected";
const setupRequired = status === "setup_required";
return (
<span
className={[
"inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium",
connected
? "border-success/30 bg-success/10 text-success"
: setupRequired
? "border-warning/30 bg-warning/10 text-warning"
: "border-base-300 bg-base-200 text-base-content/60",
].join(" ")}
>
<span
className={[
"size-1.5 rounded-full",
connected
? "bg-success"
: setupRequired
? "bg-warning"
: "bg-base-content/40",
].join(" ")}
/>
{connected
? "Connected"
: setupRequired
? "Setup required"
: "Not connected"}
</span>
</IntegrationConnectionCard>
);
}
@ -290,7 +233,7 @@ function ConnectedState({
<div className="space-y-4">
<div className="flex items-center gap-3 rounded-lg border border-base-300 bg-base-200/40 p-3.5">
<div className="grid size-9 shrink-0 place-items-center rounded-md border border-base-300 bg-base-100">
<GoogleGlyph className="size-[18px]" />
<GoogleSearchConsoleLogo className="size-5" />
</div>
<div className="min-w-0">
<p className="truncate font-mono text-sm">{siteUrl}</p>

View File

@ -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 (
<div className="alert alert-warning items-start text-sm">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<div className="space-y-1">
<p className="font-medium">Google OAuth client not configured</p>
<p className="text-base-content/70">
Add your Google client ID and secret to this OpenSEO deployment before
connecting Search Console.
</p>
<SafeExternalLink
url={GSC_SELF_HOSTED_SETUP_DOCS_URL}
label="Open setup guide"
className="inline-flex items-center gap-1 font-medium underline underline-offset-2"
/>
</div>
</div>
<GoogleOAuthSetupWarning
integrationName="Search Console"
docsUrl={GSC_SELF_HOSTED_SETUP_DOCS_URL}
/>
);
}

View File

@ -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({
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => void startGscLink(window.location.href)}
onClick={() => void startGoogleLink("gsc", window.location.href)}
>
Connect another Google account
</button>

View File

@ -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<void> {
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));
}
}

View File

@ -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 (
<div className="alert alert-warning items-start text-sm">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<div className="space-y-1">
<p className="font-medium">Google OAuth client not configured</p>
<p className="text-base-content/70">
Add your Google client ID and secret to this OpenSEO deployment before
connecting {integrationName}.
</p>
<SafeExternalLink
url={docsUrl}
label="Open setup guide"
className="inline-flex items-center gap-1 font-medium underline underline-offset-2"
/>
</div>
</div>
);
}

View File

@ -0,0 +1,68 @@
import type { SVGProps } from "react";
export function GoogleSearchConsoleLogo({
className,
...props
}: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1024 1024"
fill="none"
className={className}
{...props}
aria-hidden="true"
focusable="false"
>
<path
d="M283.674 781.491L162.842 902.349C158.365 906.809 152.303 909.313 145.984 909.313C139.665 909.313 133.603 906.809 129.126 902.349L121.651 894.874C117.199 890.398 114.7 884.342 114.7 878.029C114.7 871.716 117.199 865.66 121.651 861.184L242.483 740.326C246.961 735.859 253.028 733.349 259.354 733.349C265.679 733.349 271.746 735.859 276.224 740.326L283.674 747.776C285.891 749.988 287.651 752.615 288.851 755.508C290.052 758.401 290.67 761.502 290.67 764.634C290.67 767.766 290.052 770.867 288.851 773.759C287.651 776.652 285.891 779.28 283.674 781.491Z"
fill="#FBBC04"
/>
<path
d="M608 832H762.675C782.987 832.003 803.101 828.005 821.867 820.232C840.633 812.46 857.684 801.067 872.046 786.703C886.407 772.339 897.798 755.286 905.567 736.518C913.336 717.751 917.332 697.637 917.325 677.325V261.325C917.328 241.015 913.33 220.903 905.56 202.139C897.789 183.374 886.398 166.325 872.037 151.963C857.675 137.602 840.626 126.211 821.861 118.44C803.097 110.67 782.985 106.672 762.675 106.675C742.363 106.668 722.249 110.664 703.482 118.433C684.714 126.202 667.661 137.593 653.297 151.954C638.933 166.316 627.54 183.367 619.768 202.133C611.995 220.899 607.997 241.013 608 261.325V832Z"
fill="#4285F4"
/>
<path
d="M352 832C372.314 832.007 392.43 828.01 411.2 820.24C429.969 812.469 447.023 801.076 461.387 786.712C475.751 772.347 487.144 755.293 494.915 736.524C502.686 717.755 506.682 697.639 506.675 677.325C506.679 657.013 502.68 636.899 494.908 618.133C487.135 599.367 475.742 582.316 461.378 567.954C447.014 553.593 429.961 542.202 411.194 534.433C392.426 526.664 372.312 522.668 352 522.675C331.688 522.668 311.574 526.664 292.806 534.433C274.039 542.202 256.986 553.593 242.622 567.954C228.258 582.316 216.865 599.367 209.092 618.133C201.32 636.899 197.321 657.013 197.325 677.325C197.318 697.639 201.314 717.755 209.085 736.524C216.856 755.293 228.249 772.347 242.613 786.712C256.977 801.076 274.031 812.469 292.8 820.24C311.57 828.01 331.686 832.007 352 832Z"
fill="#FBBC04"
/>
<path
d="M716.032 832H565.325C545.013 832.003 524.899 828.005 506.133 820.232C487.367 812.46 470.316 801.067 455.954 786.703C441.593 772.339 430.202 755.286 422.433 736.518C414.664 717.751 410.668 697.637 410.675 677.325V474.675C410.668 454.363 414.664 434.249 422.433 415.482C430.202 396.714 441.593 379.661 455.954 365.297C470.316 350.933 487.367 339.54 506.133 331.768C524.899 323.995 545.013 319.997 565.325 320C585.639 319.993 605.755 323.989 624.524 331.76C643.294 339.531 660.347 350.924 674.712 365.288C689.076 379.653 700.469 396.706 708.24 415.476C716.011 434.245 720.007 454.361 720 474.675V828.058C720 829.103 719.585 830.106 718.845 830.845C718.106 831.585 717.103 832 716.058 832H716.032Z"
fill="#34A853"
/>
<path
d="M720 828.058V474.675C719.997 441.097 709.068 408.432 688.863 381.614C668.658 354.795 640.275 335.28 608 326.016V832H716.032C716.552 832.003 717.067 831.904 717.549 831.707C718.03 831.511 718.467 831.221 718.836 830.854C719.205 830.488 719.498 830.052 719.697 829.572C719.897 829.092 720 828.577 720 828.058Z"
fill="#1967D2"
/>
<path
d="M506.675 680.32C506.68 649.633 497.554 619.639 480.458 594.155C463.363 568.671 439.071 548.851 410.675 537.216V680.32C410.675 724.352 429.107 764.109 458.675 792.269C473.858 777.845 485.944 760.481 494.196 741.234C502.449 721.987 506.695 701.261 506.675 680.32Z"
fill="#EA4335"
/>
</svg>
);
}
export function GoogleAnalyticsLogo({
className,
...props
}: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 2195.9 2430.9"
className={className}
{...props}
aria-hidden="true"
focusable="false"
>
<path
d="M2195.9,2126.7c0.9,166.9-133.7,302.8-300.5,303.7c-12.4,0.1-24.9-0.6-37.2-2.1c-154.8-22.9-268.2-156.6-264.4-314V316.1c-3.7-156.6,110-291.3,264.9-314c165.7-19.4,315.8,99.2,335.2,264.9c1.4,12.2,2.1,24.4,2,36.7L2195.9,2126.7z"
fill="#F9AB00"
/>
<path
d="M301.1,1828.7c166.3,0,301.1,134.8,301.1,301.1c0,166.3-134.8,301.1-301.1,301.1C134.8,2430.9,0,2296.1,0,2129.8C0,1963.5,134.8,1828.7,301.1,1828.7z M1093.3,916.2c-167.1,9.2-296.7,149.3-292.8,316.6v808.7c0,219.5,96.6,352.7,238.1,381.1c163.3,33.1,322.4-72.4,355.5-235.7c4.1-20,6.1-40.3,6-60.7v-907.4c0.3-166.9-134.7-302.4-301.6-302.7C1096.8,916.1,1095,916.1,1093.3,916.2z"
fill="#E37400"
/>
</svg>
);
}

View File

@ -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 (
<div className="overflow-hidden rounded-xl border border-base-300 bg-base-100 shadow-sm">
<div className="flex items-start justify-between gap-4 p-5 sm:p-6">
<div className="flex min-w-0 items-center gap-2.5">
{icon ? (
<span className="grid size-8 shrink-0 place-items-center rounded-lg border border-base-300 bg-base-100 shadow-sm">
{icon}
</span>
) : null}
<h2 className="truncate text-base font-semibold leading-tight">
{title}
</h2>
</div>
{status ? <ConnectionStatusPill status={status} /> : null}
</div>
<div className="border-t border-base-300 p-5 sm:p-6">{children}</div>
</div>
);
}
function ConnectionStatusPill({
status,
}: {
status: IntegrationConnectionStatus;
}) {
const connected = status === "connected";
const setupRequired = status === "setup_required";
return (
<span
className={[
"inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium",
connected
? "border-success/30 bg-success/10 text-success"
: setupRequired
? "border-warning/30 bg-warning/10 text-warning"
: "border-base-300 bg-base-200 text-base-content/60",
].join(" ")}
>
<span
className={[
"size-1.5 rounded-full",
connected
? "bg-success"
: setupRequired
? "bg-warning"
: "bg-base-content/40",
].join(" ")}
/>
{connected
? "Connected"
: setupRequired
? "Setup required"
: "Not connected"}
</span>
);
}

View File

@ -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<void> {
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));
}
}

View File

@ -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 <Checking />;

View File

@ -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 }) {
<SearchConsoleConnectionCard projectId={projectId} />
</section>
<section id="google-analytics" className="space-y-3 scroll-mt-6">
<h2 className="text-sm font-medium text-base-content/50">Analytics</h2>
<GoogleAnalyticsConnectionCard projectId={projectId} />
</section>
<DangerSection project={project} canArchive={projects.length > 1} />
</div>
);

View File

@ -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")}{" "}
&middot; {devicesLabel(config.devices)} &middot;{" "}
{scheduleLabel(config.scheduleInterval)}
{run && (
{run?.lastCheckedAt && (
<>
{" "}
&middot; Last: {new Date(run.lastCheckedAt).toLocaleDateString()}

View File

@ -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"));

View File

@ -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)`),

View File

@ -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";

41
src/db/ga4.schema.ts Normal file
View File

@ -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,
),
],
);

View File

@ -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),
});

37
src/db/pg/ga4.schema.ts Normal file
View File

@ -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,
),
],
);

View File

@ -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";

View File

@ -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"),
);
});
});
}
});

View File

@ -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,

View File

@ -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,
},
],
}),
],

View File

@ -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

View File

@ -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),
},
},
});

View File

@ -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,
);
},
},
},

View File

@ -89,6 +89,20 @@ async function markMcpCardDismissed(projectId: string): Promise<void> {
});
}
async function markGa4CardDismissed(projectId: string): Promise<void> {
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,
};

View File

@ -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<DashboardActivation> {
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)
) {

View File

@ -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,
);
},
);
});

View File

@ -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<Ga4Connection | null> {
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<Ga4Connection> {
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<void> {
await db
.delete(ga4Connections)
.where(eq(ga4Connections.projectId, projectId));
}
async function existsForConnectorAccount(
userId: string,
ga4AccountId: string,
): Promise<boolean> {
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,
};

View File

@ -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;
}

View File

@ -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();
});
});

View File

@ -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 };

View File

@ -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,
});
});
});

View File

@ -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<string, string | number | null> | null,
previous: Record<string, string | number | null> | 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<string, string | number | null> | null,
previous: Record<string, string | number | null> | 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 };

View File

@ -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,
};
}

View File

@ -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,
});
});
});

View File

@ -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<string, string | number | null>;
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<Record<string, unknown>> = [];
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<string, Set<string>>();
for (const row of report.rows) {
if (typeof row.sessionSourceMedium !== "string") continue;
const key = row.sessionSourceMedium.toLowerCase();
const variants = caseGroups.get(key) ?? new Set<string>();
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: [] };
}

View File

@ -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<Record<string, string | number | null>>;
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<string, string | number | null> = {};
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,
};
}

View File

@ -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<Ga4RunReportResponse>>(),
}));
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,
},
});
},
);
});
});

View File

@ -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<Ga4ReportInput, "startDate" | "endDate">,
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<typeof createGa4DataClient>;
normalized: ReturnType<typeof normalizeGa4Response>;
request: Parameters<ReturnType<typeof createGa4DataClient>["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<ReturnType<typeof runReport>>;

View File

@ -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<void>>()
.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();
});
});

View File

@ -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<Ga4Connection | null> {
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<boolean> {
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<Ga4Connection> {
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<void> {
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<void> {
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,
};

View File

@ -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();
});
});

View File

@ -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<SearchOpportunityInput, "startDate" | "endDate">,
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<string, string | number | null>,
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<string, Record<string, string | number | null>>();
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"]> } =>
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 };

View File

@ -0,0 +1,88 @@
import type { Ga4Connection } from "@/server/features/ga4/repositories/Ga4ConnectionRepository";
import type { Ga4ReportResult } from "./Ga4ReportingService";
export function makeGa4Connection(
overrides: Partial<Ga4Connection> = {},
): 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<Ga4ReportResult>,
"source" | "request" | "pageInfo" | "reportMetadata"
> & {
source?: Partial<Ga4ReportResult["source"]>;
request?: Partial<Ga4ReportResult["request"]>;
pageInfo?: Partial<Ga4ReportResult["pageInfo"]>;
reportMetadata?: Partial<Ga4ReportResult["reportMetadata"]>;
};
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;
}

View File

@ -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<GoogleOAuthClientConfig | null> {
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<boolean> {
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);
}

View File

@ -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<string, string>,
) {
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",
}),
);
});
});

View File

@ -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<typeof googleTokenResponseSchema>;
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`,
);
}
}

View File

@ -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<GscOAuthClientConfig | null> {
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<boolean> {
if (!(await getGscOAuthClientConfig())) return false;
const secret = (await getOptionalEnvValue("BETTER_AUTH_SECRET"))?.trim();
return Boolean(secret && secret.length >= MIN_BETTER_AUTH_SECRET_LENGTH);
}

View File

@ -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" });

View File

@ -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<GscConnection | null> {
return GscConnectionRepository.getByProjectId(projectId);
}

View File

@ -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<boolean> {
}) {
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) {

View File

@ -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<string>();
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<typeof estimateScheduledRankCheckCredits>
| 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<typeof estimateScheduledRankCheckCredits>,
) {
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,
};

View File

@ -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();
});
});

View File

@ -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",

View File

@ -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<string>();
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<RankCheckTriggerResult> {
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,
};

View File

@ -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<void>>()
.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<void>>()
.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);
});
});

View File

@ -144,13 +144,14 @@ export async function beginRankCheckRun(input: {
billingCustomer: BillingCustomerContext;
keywordsTotal: number;
keywordIds?: string[];
maxCostCredits?: number;
trigger: "manual" | "scheduled";
workflowStartErrorMessage: string;
}): Promise<RankCheckTriggerResult> {
// 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) {

View File

@ -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",
},
});
});
});

View File

@ -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,
};
}

View File

@ -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.

View File

@ -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<typeof fetch>(),
}));
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 });
});
});

480
src/server/lib/ga4Client.ts Normal file
View File

@ -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<typeof propertySchema>;
async function getGa4AccessToken(opts: {
userId: string;
ga4AccountId: string;
}): Promise<string> {
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<string> | 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<unknown> {
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<string | null> {
const data = z
.object({ email: z.string().email().optional() })
.parse(await request(GOOGLE_USERINFO_URL));
return data.email ?? null;
},
async listProperties(): Promise<Ga4PropertySummary[]> {
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<Ga4Property> {
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<typeof runReportResponseSchema>;
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<Ga4RunReportResponse> {
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();
}
},
};
}

View File

@ -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";
}
}

View File

@ -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;

View File

@ -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";
}
}

View File

@ -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");
});

View File

@ -19,6 +19,10 @@ type ToolHandler<TArgs> = (
extra: ToolExtra,
) => CallToolResult | Promise<CallToolResult>;
function isRecord(value: unknown): value is Record<string, unknown> {
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<TArgs> = (
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<TArgs>(
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<TArgs>(
);
}
}
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<TArgs>(
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);

View File

@ -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<In>;
},
) {
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<In>
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<In>,
);
}
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);
}

View File

@ -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<z.ZodObject<typeof inputSchema>>;
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,
},
});
}),
};

View File

@ -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" },

View File

@ -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<z.ZodObject<typeof inputSchema>>;
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,
},
});
}),
};

View File

@ -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(
{

View File

@ -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(

View File

@ -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(
{

View File

@ -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<z.ZodObject<typeof inputSchema>>;
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 },
});
}),
};

View File

@ -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(

View File

@ -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);
}
});
});

View File

@ -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<string, z.ZodType>) {
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<string, unknown>)[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<ReturnType<typeof Ga4ReportingService.runReport>>,
) {
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<TArgs extends ProjectArgs>(
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<typeof landingPageInputSchema>;
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<LandingPageArgs>(
"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<typeof pagePerformanceInputSchema>;
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<PagePerformanceArgs>(
"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<typeof keyEventsInputSchema>;
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<KeyEventsArgs>(
"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<typeof opportunityInputSchema>;
export const getSearchOpportunitiesTool = {
name: "get_search_opportunities",
config: {
title: "Get search opportunities",
description:
"Join Search Console pages ranking in positions 420 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<typeof overviewInputSchema>;
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<typeof trafficAcquisitionInputSchema>;
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<TrafficAcquisitionArgs>(
"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<typeof ecommerceInputSchema>;
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<EcommerceArgs>(
"Ecommerce performance",
(args) => ({
...args,
kind: "ecommerce_performance",
ecommerceBreakdown: args.breakdown,
ecommerceOnlyWithTransactions: args.onlyWithTransactions,
breakdown: undefined,
}),
),
};
const siteSearchInputSchema = z.strictObject(commonAnalyticsInputSchema);
type SiteSearchArgs = z.infer<typeof siteSearchInputSchema>;
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<SiteSearchArgs>(
"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<typeof audienceInputSchema>;
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<AudienceArgs>(
"Audience breakdown",
(args) => ({
...args,
kind: "audience_breakdown",
audienceBreakdown: args.breakdown,
breakdown: undefined,
}),
),
};
const measurementHealthInputSchema = z.strictObject({
projectId: projectIdSchema,
});
type MeasurementHealthArgs = z.infer<typeof measurementHealthInputSchema>;
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);
}
}),
};

View File

@ -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(

View File

@ -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<unknown>) => 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" },
});
});
});

View File

@ -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<z.ZodObject<typeof inputSchema>>;
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,
},
});
}),
};

View File

@ -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<z.ZodObject<typeof inputSchema>>;
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,
},
});
}),
};

View File

@ -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(
{

View File

@ -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"] },

Some files were not shown because too many files have changed in this diff Show More