From 53a83996ef871971e5502206abe9a28551dff9f5 Mon Sep 17 00:00:00 2001
From: Ben Senescu <44480372+bensenescu@users.noreply.github.com>
Date: Tue, 14 Apr 2026 19:19:54 -0400
Subject: [PATCH] feat: Rank Tracking MVP (#91)
---
.oxlintrc.json | 5 +-
README.md | 27 +-
drizzle/0007_sour_risque.sql | 67 +
drizzle/meta/0007_snapshot.json | 1996 +++++++++++++++++
drizzle/meta/_journal.json | 7 +
src/client/components/Modal.tsx | 19 +
.../backlinks/BacklinksTableHeaders.tsx | 1 +
.../backlinks/backlinksTableSorting.ts | 2 +
.../features/rank-tracking/ActionsMenu.tsx | 68 +
.../rank-tracking/AddKeywordsPanel.tsx | 66 +
.../rank-tracking/CheckConfirmModal.tsx | 69 +
.../rank-tracking/RankTrackingConfigModal.tsx | 220 ++
.../RankTrackingDomainDetail.tsx | 330 +++
.../rank-tracking/RankTrackingDomainList.tsx | 106 +
.../rank-tracking/RankTrackingFilters.tsx | 193 ++
.../rank-tracking/RankTrackingPage.tsx | 107 +
.../rank-tracking/RankTrackingTable.tsx | 245 ++
.../rank-tracking/RankTrackingTableParts.tsx | 265 +++
.../rank-tracking/useRankCheckTrigger.ts | 59 +
.../rank-tracking/useRankRunPolling.ts | 39 +
.../rank-tracking/useRankTableSort.ts | 37 +
src/client/navigation/items.ts | 7 +
src/db/app.schema.ts | 147 ++
src/routeTree.gen.ts | 22 +
.../_project/p/$projectId/rank-tracking.tsx | 11 +
src/routes/_project/p/$projectId/saved.tsx | 336 +--
src/server.ts | 107 +
.../repositories/RankTrackingRepository.ts | 411 ++++
.../services/RankTrackingService.ts | 311 +++
.../services/rankCheckRunGuards.ts | 272 +++
.../services/rankTrackingResults.ts | 155 ++
src/server/lib/dataforseo.ts | 98 +
src/server/lib/dataforseoClient.ts | 25 +-
src/server/workflows/RankCheckWorkflow.ts | 307 +++
src/server/workflows/rankCheckPaths.ts | 113 +
src/serverFunctions/audit.ts | 6 +-
src/serverFunctions/rank-tracking.ts | 154 ++
src/shared/rank-tracking.ts | 97 +
src/types/schemas/rank-tracking.ts | 120 +
worker-configuration.d.ts | 19 +-
wrangler.jsonc | 8 +
41 files changed, 6495 insertions(+), 159 deletions(-)
create mode 100644 drizzle/0007_sour_risque.sql
create mode 100644 drizzle/meta/0007_snapshot.json
create mode 100644 src/client/components/Modal.tsx
create mode 100644 src/client/features/rank-tracking/ActionsMenu.tsx
create mode 100644 src/client/features/rank-tracking/AddKeywordsPanel.tsx
create mode 100644 src/client/features/rank-tracking/CheckConfirmModal.tsx
create mode 100644 src/client/features/rank-tracking/RankTrackingConfigModal.tsx
create mode 100644 src/client/features/rank-tracking/RankTrackingDomainDetail.tsx
create mode 100644 src/client/features/rank-tracking/RankTrackingDomainList.tsx
create mode 100644 src/client/features/rank-tracking/RankTrackingFilters.tsx
create mode 100644 src/client/features/rank-tracking/RankTrackingPage.tsx
create mode 100644 src/client/features/rank-tracking/RankTrackingTable.tsx
create mode 100644 src/client/features/rank-tracking/RankTrackingTableParts.tsx
create mode 100644 src/client/features/rank-tracking/useRankCheckTrigger.ts
create mode 100644 src/client/features/rank-tracking/useRankRunPolling.ts
create mode 100644 src/client/features/rank-tracking/useRankTableSort.ts
create mode 100644 src/routes/_project/p/$projectId/rank-tracking.tsx
create mode 100644 src/server/features/rank-tracking/repositories/RankTrackingRepository.ts
create mode 100644 src/server/features/rank-tracking/services/RankTrackingService.ts
create mode 100644 src/server/features/rank-tracking/services/rankCheckRunGuards.ts
create mode 100644 src/server/features/rank-tracking/services/rankTrackingResults.ts
create mode 100644 src/server/workflows/RankCheckWorkflow.ts
create mode 100644 src/server/workflows/rankCheckPaths.ts
create mode 100644 src/serverFunctions/rank-tracking.ts
create mode 100644 src/shared/rank-tracking.ts
create mode 100644 src/types/schemas/rank-tracking.ts
diff --git a/.oxlintrc.json b/.oxlintrc.json
index 2d4e5b5..af7c445 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -11,7 +11,8 @@
".output",
"web",
"src/routeTree.gen.ts",
- "worker-configuration.d.ts"
+ "worker-configuration.d.ts",
+ "scripts"
],
"rules": {
"react/react-in-jsx-scope": "off",
@@ -36,7 +37,7 @@
"eslint/complexity": ["error", { "max": 40 }],
"eslint/max-lines": [
"error",
- { "max": 350, "skipBlankLines": true, "skipComments": true }
+ { "max": 400, "skipBlankLines": true, "skipComments": true }
],
"eslint/max-lines-per-function": [
"error",
diff --git a/README.md b/README.md
index 72d739a..f30c7fc 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,8 @@ OpenSEO is an SEO tool for _the people_. If tools like Semrush or Ahrefs are too
- Keyword research
- Find topics worth targeting, estimate demand, and prioritize what to write next.
+- Rank tracking
+ - Monitor keyword positions across desktop and mobile over time, with SERP feature detection.
- Domain insights
- Understand where your domain is gaining or losing visibility so you can focus on the pages that move revenue.
- Backlinks
@@ -41,8 +43,9 @@ OpenSEO is an SEO tool for _the people_. If tools like Semrush or Ahrefs are too
Top priorities:
-- Rank tracking
-- AI SEO like LLM Citations
+- AI SEO, GEO, LLM Visibility
+- MCP for Claude
+- Making the best agentic workflows for SEO
If something important is missing, please join the [Discord](https://discord.gg/c9uGs3cFXr) or email me at ben@everyapp.dev and request it.
@@ -241,15 +244,25 @@ That means you can try OpenSEO for free with the starter credit, then decide if/
### Pricing sources
+- DataForSEO SERP API pricing: https://dataforseo.com/apis/serp-api/pricing
- DataForSEO Labs pricing: https://dataforseo.com/pricing/dataforseo-labs/dataforseo-google-api
- DataForSEO Backlinks pricing: https://dataforseo.com/pricing/backlinks/backlinks
- DataForSEO Lighthouse API docs: https://docs.dataforseo.com/v3/on_page/lighthouse/overview/
-### 1) Site audit
+### 1) Rank tracking
-- No paid API calls in the current implementation.
+- Uses the SERP Task API with `depth: 100` + `stop_crawl_on_match` (stops early when your domain is found).
+- Cost per keyword per device: `$0.0006` (ranks top 10) to `$0.00465` (not ranking).
+- Typical cost per keyword (both devices): `~$0.002` assuming most keywords rank in the top 20.
+- Planning examples:
+ - 100 keywords, both devices, weekly: `~$0.80/month`
+ - 500 keywords, both devices, weekly: `~$4.00/month`
-### 2) Keyword research (`related` mode)
+### 2) Site audit
+
+- $0.01 per 20 pages audited with Lighthouse
+
+### 3) Keyword research (`related` mode)
- Current billed cost pattern (from account usage logs):
- `0.02 + (0.0001 x returned_keywords)` USD
@@ -259,13 +272,13 @@ That means you can try OpenSEO for free with the starter credit, then decide if/
- 300 results = `$0.05`
- 500 results = `$0.07`
-### 3) Domain overview
+### 4) Domain overview
- Standard domain overview request (with top 200 ranked keywords): `$0.0401` per domain.
- General formula if needed:
- `0.0201 + (0.0001 x ranked_keywords_returned)` USD
-### 4) Backlinks search
+### 5) Backlinks search
- Backlinks search costs about `$0.06` for a domain or `$0.04` for a page.
- Opening extra tabs like `Referring Domains` or `Top Pages` adds about `+$0.02` each.
diff --git a/drizzle/0007_sour_risque.sql b/drizzle/0007_sour_risque.sql
new file mode 100644
index 0000000..5900670
--- /dev/null
+++ b/drizzle/0007_sour_risque.sql
@@ -0,0 +1,67 @@
+CREATE TABLE `rank_check_locks` (
+ `config_id` text PRIMARY KEY NOT NULL,
+ `run_id` text NOT NULL,
+ `acquired_at` text DEFAULT (current_timestamp) NOT NULL,
+ FOREIGN KEY (`config_id`) REFERENCES `rank_tracking_configs`(`id`) ON UPDATE no action ON DELETE cascade
+);
+--> statement-breakpoint
+CREATE INDEX `rank_check_locks_run_idx` ON `rank_check_locks` (`run_id`);--> statement-breakpoint
+CREATE TABLE `rank_check_runs` (
+ `id` text PRIMARY KEY NOT NULL,
+ `config_id` text NOT NULL,
+ `project_id` text NOT NULL,
+ `status` text DEFAULT 'pending' NOT NULL,
+ `keywords_total` integer DEFAULT 0 NOT NULL,
+ `keywords_checked` integer DEFAULT 0 NOT NULL,
+ `is_subset_run` integer DEFAULT false NOT NULL,
+ `error_message` text,
+ `started_at` text DEFAULT (current_timestamp) NOT NULL,
+ `completed_at` text,
+ FOREIGN KEY (`config_id`) REFERENCES `rank_tracking_configs`(`id`) ON UPDATE no action ON DELETE cascade,
+ FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
+);
+--> statement-breakpoint
+CREATE INDEX `rank_check_runs_config_idx` ON `rank_check_runs` (`config_id`,`started_at`);--> statement-breakpoint
+CREATE INDEX `rank_check_runs_project_idx` ON `rank_check_runs` (`project_id`,`started_at`);--> statement-breakpoint
+CREATE TABLE `rank_snapshots` (
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
+ `run_id` text NOT NULL,
+ `tracking_keyword_id` text NOT NULL,
+ `keyword` text NOT NULL,
+ `device` text NOT NULL,
+ `position` integer,
+ `url` text,
+ `serp_features` text,
+ `checked_at` text DEFAULT (current_timestamp) NOT NULL,
+ FOREIGN KEY (`run_id`) REFERENCES `rank_check_runs`(`id`) ON UPDATE no action ON DELETE cascade
+);
+--> statement-breakpoint
+CREATE INDEX `rank_snapshots_run_idx` ON `rank_snapshots` (`run_id`);--> statement-breakpoint
+CREATE INDEX `rank_snapshots_keyword_device_idx` ON `rank_snapshots` (`tracking_keyword_id`,`device`,`checked_at`);--> statement-breakpoint
+CREATE UNIQUE INDEX `rank_snapshots_run_keyword_device_idx` ON `rank_snapshots` (`run_id`,`tracking_keyword_id`,`device`);--> statement-breakpoint
+CREATE TABLE `rank_tracking_configs` (
+ `id` text PRIMARY KEY NOT NULL,
+ `project_id` text NOT NULL,
+ `domain` text NOT NULL,
+ `location_code` integer DEFAULT 2840 NOT NULL,
+ `language_code` text DEFAULT 'en' NOT NULL,
+ `devices` text DEFAULT 'both' NOT NULL,
+ `schedule_interval` text DEFAULT 'weekly' NOT NULL,
+ `is_active` integer DEFAULT true NOT NULL,
+ `last_checked_at` text,
+ `next_check_at` text,
+ `last_skip_reason` text,
+ `created_at` text DEFAULT (current_timestamp) NOT NULL,
+ FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX `rank_tracking_configs_project_domain_location_idx` ON `rank_tracking_configs` (`project_id`,`domain`,`location_code`);--> statement-breakpoint
+CREATE TABLE `rank_tracking_keywords` (
+ `id` text PRIMARY KEY NOT NULL,
+ `config_id` text NOT NULL,
+ `keyword` text NOT NULL,
+ `created_at` text DEFAULT (current_timestamp) NOT NULL,
+ FOREIGN KEY (`config_id`) REFERENCES `rank_tracking_configs`(`id`) ON UPDATE no action ON DELETE cascade
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX `rank_tracking_keywords_config_keyword_idx` ON `rank_tracking_keywords` (`config_id`,`keyword`);
\ No newline at end of file
diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json
new file mode 100644
index 0000000..dc9f550
--- /dev/null
+++ b/drizzle/meta/0007_snapshot.json
@@ -0,0 +1,1996 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "302b1d9c-6be4-4786-a420-cf583f195bc4",
+ "prevId": "8c42b2c6-ff48-439b-9a32-aab0366ae6ff",
+ "tables": {
+ "audit_lighthouse_results": {
+ "name": "audit_lighthouse_results",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "audit_id": {
+ "name": "audit_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "page_id": {
+ "name": "page_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "strategy": {
+ "name": "strategy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "performance_score": {
+ "name": "performance_score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "accessibility_score": {
+ "name": "accessibility_score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "best_practices_score": {
+ "name": "best_practices_score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "seo_score": {
+ "name": "seo_score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lcp_ms": {
+ "name": "lcp_ms",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cls": {
+ "name": "cls",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inp_ms": {
+ "name": "inp_ms",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ttfb_ms": {
+ "name": "ttfb_ms",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "payload_size_bytes": {
+ "name": "payload_size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "audit_lighthouse_results_audit_id_idx": {
+ "name": "audit_lighthouse_results_audit_id_idx",
+ "columns": [
+ "audit_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "audit_lighthouse_results_audit_id_audits_id_fk": {
+ "name": "audit_lighthouse_results_audit_id_audits_id_fk",
+ "tableFrom": "audit_lighthouse_results",
+ "tableTo": "audits",
+ "columnsFrom": [
+ "audit_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "audit_lighthouse_results_page_id_audit_pages_id_fk": {
+ "name": "audit_lighthouse_results_page_id_audit_pages_id_fk",
+ "tableFrom": "audit_lighthouse_results",
+ "tableTo": "audit_pages",
+ "columnsFrom": [
+ "page_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "audit_pages": {
+ "name": "audit_pages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "audit_id": {
+ "name": "audit_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status_code": {
+ "name": "status_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "redirect_url": {
+ "name": "redirect_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "meta_description": {
+ "name": "meta_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "canonical_url": {
+ "name": "canonical_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "robots_meta": {
+ "name": "robots_meta",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "og_title": {
+ "name": "og_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "og_description": {
+ "name": "og_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "og_image": {
+ "name": "og_image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "h1_count": {
+ "name": "h1_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "h2_count": {
+ "name": "h2_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "h3_count": {
+ "name": "h3_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "h4_count": {
+ "name": "h4_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "h5_count": {
+ "name": "h5_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "h6_count": {
+ "name": "h6_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "heading_order_json": {
+ "name": "heading_order_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "word_count": {
+ "name": "word_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "images_total": {
+ "name": "images_total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "images_missing_alt": {
+ "name": "images_missing_alt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "images_json": {
+ "name": "images_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "internal_link_count": {
+ "name": "internal_link_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "external_link_count": {
+ "name": "external_link_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "has_structured_data": {
+ "name": "has_structured_data",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "hreflang_tags_json": {
+ "name": "hreflang_tags_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_indexable": {
+ "name": "is_indexable",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "response_time_ms": {
+ "name": "response_time_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "audit_pages_audit_id_idx": {
+ "name": "audit_pages_audit_id_idx",
+ "columns": [
+ "audit_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "audit_pages_audit_id_audits_id_fk": {
+ "name": "audit_pages_audit_id_audits_id_fk",
+ "tableFrom": "audit_pages",
+ "tableTo": "audits",
+ "columnsFrom": [
+ "audit_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "audits": {
+ "name": "audits",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "started_by_user_id": {
+ "name": "started_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "start_url": {
+ "name": "start_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'running'"
+ },
+ "workflow_instance_id": {
+ "name": "workflow_instance_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "config": {
+ "name": "config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{}'"
+ },
+ "pages_crawled": {
+ "name": "pages_crawled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "pages_total": {
+ "name": "pages_total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "lighthouse_total": {
+ "name": "lighthouse_total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "lighthouse_completed": {
+ "name": "lighthouse_completed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "lighthouse_failed": {
+ "name": "lighthouse_failed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "current_phase": {
+ "name": "current_phase",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'discovery'"
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "audits_project_id_idx": {
+ "name": "audits_project_id_idx",
+ "columns": [
+ "project_id"
+ ],
+ "isUnique": false
+ },
+ "audits_started_by_user_id_idx": {
+ "name": "audits_started_by_user_id_idx",
+ "columns": [
+ "started_by_user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "audits_project_id_projects_id_fk": {
+ "name": "audits_project_id_projects_id_fk",
+ "tableFrom": "audits",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "delegated_users": {
+ "name": "delegated_users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ }
+ },
+ "indexes": {
+ "delegated_users_email_unique": {
+ "name": "delegated_users_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "keyword_metrics": {
+ "name": "keyword_metrics",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyword": {
+ "name": "keyword",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "location_code": {
+ "name": "location_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "language_code": {
+ "name": "language_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'en'"
+ },
+ "search_volume": {
+ "name": "search_volume",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cpc": {
+ "name": "cpc",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "competition": {
+ "name": "competition",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "keyword_difficulty": {
+ "name": "keyword_difficulty",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "intent": {
+ "name": "intent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "monthly_searches": {
+ "name": "monthly_searches",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fetched_at": {
+ "name": "fetched_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ }
+ },
+ "indexes": {
+ "keyword_metrics_unique_project_keyword_location_language": {
+ "name": "keyword_metrics_unique_project_keyword_location_language",
+ "columns": [
+ "project_id",
+ "keyword",
+ "location_code",
+ "language_code"
+ ],
+ "isUnique": true
+ },
+ "keyword_metrics_lookup_idx": {
+ "name": "keyword_metrics_lookup_idx",
+ "columns": [
+ "project_id",
+ "keyword",
+ "location_code",
+ "language_code",
+ "fetched_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "keyword_metrics_project_id_projects_id_fk": {
+ "name": "keyword_metrics_project_id_projects_id_fk",
+ "tableFrom": "keyword_metrics",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "projects": {
+ "name": "projects",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "domain": {
+ "name": "domain",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "projects_organization_id_organization_id_fk": {
+ "name": "projects_organization_id_organization_id_fk",
+ "tableFrom": "projects",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rank_check_locks": {
+ "name": "rank_check_locks",
+ "columns": {
+ "config_id": {
+ "name": "config_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "run_id": {
+ "name": "run_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "acquired_at": {
+ "name": "acquired_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ }
+ },
+ "indexes": {
+ "rank_check_locks_run_idx": {
+ "name": "rank_check_locks_run_idx",
+ "columns": [
+ "run_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "rank_check_locks_config_id_rank_tracking_configs_id_fk": {
+ "name": "rank_check_locks_config_id_rank_tracking_configs_id_fk",
+ "tableFrom": "rank_check_locks",
+ "tableTo": "rank_tracking_configs",
+ "columnsFrom": [
+ "config_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rank_check_runs": {
+ "name": "rank_check_runs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "config_id": {
+ "name": "config_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "keywords_total": {
+ "name": "keywords_total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "keywords_checked": {
+ "name": "keywords_checked",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_subset_run": {
+ "name": "is_subset_run",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "rank_check_runs_config_idx": {
+ "name": "rank_check_runs_config_idx",
+ "columns": [
+ "config_id",
+ "started_at"
+ ],
+ "isUnique": false
+ },
+ "rank_check_runs_project_idx": {
+ "name": "rank_check_runs_project_idx",
+ "columns": [
+ "project_id",
+ "started_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "rank_check_runs_config_id_rank_tracking_configs_id_fk": {
+ "name": "rank_check_runs_config_id_rank_tracking_configs_id_fk",
+ "tableFrom": "rank_check_runs",
+ "tableTo": "rank_tracking_configs",
+ "columnsFrom": [
+ "config_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "rank_check_runs_project_id_projects_id_fk": {
+ "name": "rank_check_runs_project_id_projects_id_fk",
+ "tableFrom": "rank_check_runs",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rank_snapshots": {
+ "name": "rank_snapshots",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "run_id": {
+ "name": "run_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tracking_keyword_id": {
+ "name": "tracking_keyword_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyword": {
+ "name": "keyword",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "device": {
+ "name": "device",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "serp_features": {
+ "name": "serp_features",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "checked_at": {
+ "name": "checked_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ }
+ },
+ "indexes": {
+ "rank_snapshots_run_idx": {
+ "name": "rank_snapshots_run_idx",
+ "columns": [
+ "run_id"
+ ],
+ "isUnique": false
+ },
+ "rank_snapshots_keyword_device_idx": {
+ "name": "rank_snapshots_keyword_device_idx",
+ "columns": [
+ "tracking_keyword_id",
+ "device",
+ "checked_at"
+ ],
+ "isUnique": false
+ },
+ "rank_snapshots_run_keyword_device_idx": {
+ "name": "rank_snapshots_run_keyword_device_idx",
+ "columns": [
+ "run_id",
+ "tracking_keyword_id",
+ "device"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "rank_snapshots_run_id_rank_check_runs_id_fk": {
+ "name": "rank_snapshots_run_id_rank_check_runs_id_fk",
+ "tableFrom": "rank_snapshots",
+ "tableTo": "rank_check_runs",
+ "columnsFrom": [
+ "run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rank_tracking_configs": {
+ "name": "rank_tracking_configs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "domain": {
+ "name": "domain",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "location_code": {
+ "name": "location_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 2840
+ },
+ "language_code": {
+ "name": "language_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'en'"
+ },
+ "devices": {
+ "name": "devices",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'both'"
+ },
+ "schedule_interval": {
+ "name": "schedule_interval",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'weekly'"
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "last_checked_at": {
+ "name": "last_checked_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "next_check_at": {
+ "name": "next_check_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_skip_reason": {
+ "name": "last_skip_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ }
+ },
+ "indexes": {
+ "rank_tracking_configs_project_domain_location_idx": {
+ "name": "rank_tracking_configs_project_domain_location_idx",
+ "columns": [
+ "project_id",
+ "domain",
+ "location_code"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "rank_tracking_configs_project_id_projects_id_fk": {
+ "name": "rank_tracking_configs_project_id_projects_id_fk",
+ "tableFrom": "rank_tracking_configs",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rank_tracking_keywords": {
+ "name": "rank_tracking_keywords",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "config_id": {
+ "name": "config_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyword": {
+ "name": "keyword",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ }
+ },
+ "indexes": {
+ "rank_tracking_keywords_config_keyword_idx": {
+ "name": "rank_tracking_keywords_config_keyword_idx",
+ "columns": [
+ "config_id",
+ "keyword"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "rank_tracking_keywords_config_id_rank_tracking_configs_id_fk": {
+ "name": "rank_tracking_keywords_config_id_rank_tracking_configs_id_fk",
+ "tableFrom": "rank_tracking_keywords",
+ "tableTo": "rank_tracking_configs",
+ "columnsFrom": [
+ "config_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "saved_keywords": {
+ "name": "saved_keywords",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyword": {
+ "name": "keyword",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "location_code": {
+ "name": "location_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 2840
+ },
+ "language_code": {
+ "name": "language_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'en'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(current_timestamp)"
+ }
+ },
+ "indexes": {
+ "saved_keywords_unique_project_keyword_location_language": {
+ "name": "saved_keywords_unique_project_keyword_location_language",
+ "columns": [
+ "project_id",
+ "keyword",
+ "location_code",
+ "language_code"
+ ],
+ "isUnique": true
+ },
+ "saved_keywords_project_created_idx": {
+ "name": "saved_keywords_project_created_idx",
+ "columns": [
+ "project_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "saved_keywords_project_id_projects_id_fk": {
+ "name": "saved_keywords_project_id_projects_id_fk",
+ "tableFrom": "saved_keywords",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "account": {
+ "name": "account",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "account_userId_idx": {
+ "name": "account_userId_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "invitation": {
+ "name": "invitation",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "inviter_id": {
+ "name": "inviter_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "invitation_organizationId_idx": {
+ "name": "invitation_organizationId_idx",
+ "columns": [
+ "organization_id"
+ ],
+ "isUnique": false
+ },
+ "invitation_email_idx": {
+ "name": "invitation_email_idx",
+ "columns": [
+ "email"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "invitation_organization_id_organization_id_fk": {
+ "name": "invitation_organization_id_organization_id_fk",
+ "tableFrom": "invitation",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "invitation_inviter_id_user_id_fk": {
+ "name": "invitation_inviter_id_user_id_fk",
+ "tableFrom": "invitation",
+ "tableTo": "user",
+ "columnsFrom": [
+ "inviter_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "member": {
+ "name": "member",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'member'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "member_organizationId_idx": {
+ "name": "member_organizationId_idx",
+ "columns": [
+ "organization_id"
+ ],
+ "isUnique": false
+ },
+ "member_userId_idx": {
+ "name": "member_userId_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "member_organization_id_organization_id_fk": {
+ "name": "member_organization_id_organization_id_fk",
+ "tableFrom": "member",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "member_user_id_user_id_fk": {
+ "name": "member_user_id_user_id_fk",
+ "tableFrom": "member",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "organization": {
+ "name": "organization",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "logo": {
+ "name": "logo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "organization_slug_unique": {
+ "name": "organization_slug_unique",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": true
+ },
+ "organization_slug_uidx": {
+ "name": "organization_slug_uidx",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "active_organization_id": {
+ "name": "active_organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "session_userId_idx": {
+ "name": "session_userId_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "verification": {
+ "name": "verification",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ "identifier"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 8528c0f..2d248d1 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -50,6 +50,13 @@
"when": 1774320825595,
"tag": "0006_magical_alex_wilder",
"breakpoints": true
+ },
+ {
+ "idx": 7,
+ "version": "6",
+ "when": 1776208279781,
+ "tag": "0007_sour_risque",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/src/client/components/Modal.tsx b/src/client/components/Modal.tsx
new file mode 100644
index 0000000..68eafa2
--- /dev/null
+++ b/src/client/components/Modal.tsx
@@ -0,0 +1,19 @@
+import type { ReactNode } from "react";
+
+export function Modal({
+ maxWidth = "max-w-sm",
+ children,
+}: {
+ maxWidth?: string;
+ children: ReactNode;
+}) {
+ return (
+
+ );
+}
diff --git a/src/client/features/backlinks/BacklinksTableHeaders.tsx b/src/client/features/backlinks/BacklinksTableHeaders.tsx
index 94b9ea6..e323878 100644
--- a/src/client/features/backlinks/BacklinksTableHeaders.tsx
+++ b/src/client/features/backlinks/BacklinksTableHeaders.tsx
@@ -6,6 +6,7 @@ import {
type SortDirection,
type TopPagesTableSort,
} from "./backlinksTableSorting";
+
export function ReferringDomainsTableHeader({
sort,
onSortChange,
diff --git a/src/client/features/backlinks/backlinksTableSorting.ts b/src/client/features/backlinks/backlinksTableSorting.ts
index f036f33..2597aae 100644
--- a/src/client/features/backlinks/backlinksTableSorting.ts
+++ b/src/client/features/backlinks/backlinksTableSorting.ts
@@ -1,6 +1,7 @@
import type { BacklinksOverviewData } from "./backlinksPageTypes";
export type SortDirection = "asc" | "desc";
+
export type ReferringDomainsTableSortField =
| "domain"
| "backlinks"
@@ -50,6 +51,7 @@ export function getNextSort(
direction: current.direction === "asc" ? "desc" : "asc",
};
}
+
export function sortReferringDomainRows(
rows: BacklinksOverviewData["referringDomains"],
sort: ReferringDomainsTableSort,
diff --git a/src/client/features/rank-tracking/ActionsMenu.tsx b/src/client/features/rank-tracking/ActionsMenu.tsx
new file mode 100644
index 0000000..280ab7d
--- /dev/null
+++ b/src/client/features/rank-tracking/ActionsMenu.tsx
@@ -0,0 +1,68 @@
+import { useState } from "react";
+import { MoreHorizontal, Play, Download, Copy } from "lucide-react";
+
+export function ActionsMenu({
+ onCheckNow,
+ onExport,
+ onCopyKeywords,
+ isRunning,
+ hasData,
+}: {
+ onCheckNow: () => void;
+ onExport: () => void;
+ onCopyKeywords: () => void;
+ isRunning: boolean;
+ hasData: boolean;
+}) {
+ const [open, setOpen] = useState(false);
+ return (
+
+
setOpen((c) => !c)}
+ >
+
+
+ {open && (
+ <>
+
setOpen(false)} />
+
+
{
+ onCheckNow();
+ setOpen(false);
+ }}
+ disabled={isRunning}
+ >
+
+ {isRunning ? "Running..." : "Check Now"}
+
+
{
+ onExport();
+ setOpen(false);
+ }}
+ disabled={!hasData}
+ >
+
+ Export CSV
+
+
{
+ onCopyKeywords();
+ setOpen(false);
+ }}
+ disabled={!hasData}
+ >
+
+ Copy Keywords
+
+
+ >
+ )}
+
+ );
+}
diff --git a/src/client/features/rank-tracking/AddKeywordsPanel.tsx b/src/client/features/rank-tracking/AddKeywordsPanel.tsx
new file mode 100644
index 0000000..8634277
--- /dev/null
+++ b/src/client/features/rank-tracking/AddKeywordsPanel.tsx
@@ -0,0 +1,66 @@
+import { useState } from "react";
+import { toast } from "sonner";
+import { useMutation } from "@tanstack/react-query";
+import { addTrackingKeywords } from "@/serverFunctions/rank-tracking";
+import { getStandardErrorMessage } from "@/client/lib/error-messages";
+import { Loader2 } from "lucide-react";
+
+export function AddKeywordsPanel({
+ configId,
+ projectId,
+ onSuccess,
+ onCancel,
+}: {
+ configId: string;
+ projectId: string;
+ onSuccess: (result: { added: number; addedIds?: string[] }) => void;
+ onCancel: () => void;
+}) {
+ const [keywordInput, setKeywordInput] = useState("");
+ const mutation = useMutation({
+ mutationFn: (kws: string[]) =>
+ addTrackingKeywords({ data: { projectId, configId, keywords: kws } }),
+ onSuccess: (result) => {
+ setKeywordInput("");
+ onSuccess(result);
+ },
+ onError: (error) => {
+ toast.error(getStandardErrorMessage(error, "Failed to add keywords"));
+ },
+ });
+ const isPending = mutation.isPending;
+ return (
+
+ );
+}
diff --git a/src/client/features/rank-tracking/CheckConfirmModal.tsx b/src/client/features/rank-tracking/CheckConfirmModal.tsx
new file mode 100644
index 0000000..8beec71
--- /dev/null
+++ b/src/client/features/rank-tracking/CheckConfirmModal.tsx
@@ -0,0 +1,69 @@
+import { Loader2, Zap } from "lucide-react";
+import { Modal } from "@/client/components/Modal";
+import type { RankTrackingConfig } from "@/types/schemas/rank-tracking";
+import {
+ estimateRankCheckCredits,
+ devicesCount,
+ KEYWORDS_PER_BATCH,
+ SECONDS_PER_BATCH,
+} from "@/shared/rank-tracking";
+
+export function CheckConfirmModal({
+ keywordCount,
+ devices,
+ isPending,
+ onRunNow,
+ onCancel,
+}: {
+ keywordCount: number;
+ devices: RankTrackingConfig["devices"];
+ isPending: boolean;
+ onRunNow: () => void;
+ onCancel: () => void;
+}) {
+ const { costUsd } = estimateRankCheckCredits(keywordCount, devices);
+ const dc = devicesCount(devices);
+ const totalChecks = keywordCount * dc;
+ const liveTime =
+ Math.ceil(totalChecks / KEYWORDS_PER_BATCH) * SECONDS_PER_BATCH;
+
+ return (
+
+
+
+ Check {keywordCount} keyword
+ {keywordCount !== 1 ? "s" : ""}
+
+
+ {keywordCount} keywords × {dc} device
+ {dc !== 1 ? "s" : ""} = {totalChecks} SERP checks
+
+
+
+
+
+
+
+
+
Run Now
+
+ Results in ~
+ {liveTime < 60 ? `${liveTime}s` : `${Math.ceil(liveTime / 60)} min`}
+
+
+
+
${costUsd.toFixed(2)}
+ {isPending &&
}
+
+
+
+
+ Cancel
+
+
+ );
+}
diff --git a/src/client/features/rank-tracking/RankTrackingConfigModal.tsx b/src/client/features/rank-tracking/RankTrackingConfigModal.tsx
new file mode 100644
index 0000000..3240445
--- /dev/null
+++ b/src/client/features/rank-tracking/RankTrackingConfigModal.tsx
@@ -0,0 +1,220 @@
+import { useState } from "react";
+import { toast } from "sonner";
+import { useMutation } from "@tanstack/react-query";
+import {
+ createRankTrackingConfig,
+ updateRankTrackingConfig,
+} from "@/serverFunctions/rank-tracking";
+import { Loader2, X } from "lucide-react";
+import { Modal } from "@/client/components/Modal";
+import { getStandardErrorMessage } from "@/client/lib/error-messages";
+import { captureClientEvent } from "@/client/lib/posthog";
+import type { RankTrackingConfig } from "@/types/schemas/rank-tracking";
+import {
+ LOCATION_OPTIONS,
+ DEFAULT_LOCATION_CODE,
+ getLanguageCode,
+} from "@/client/features/keywords/locations";
+
+type Props = {
+ projectId: string;
+ existingConfig?: RankTrackingConfig | null;
+ onClose: () => void;
+ onSaved: () => void;
+};
+
+export function RankTrackingConfigModal({
+ projectId,
+ existingConfig,
+ onClose,
+ onSaved,
+}: Props) {
+ const isEdit = !!existingConfig;
+ const [domain, setDomain] = useState(existingConfig?.domain ?? "");
+ const [devices, setDevices] = useState<"both" | "desktop" | "mobile">(
+ existingConfig?.devices ?? "mobile",
+ );
+ const [locationCode, setLocationCode] = useState(
+ existingConfig?.locationCode ?? DEFAULT_LOCATION_CODE,
+ );
+ const [schedule, setSchedule] = useState<"daily" | "weekly" | "manual">(
+ existingConfig?.scheduleInterval ?? "weekly",
+ );
+
+ const createMutation = useMutation({
+ mutationFn: () =>
+ createRankTrackingConfig({
+ data: {
+ projectId,
+ domain,
+ devices,
+ locationCode,
+ languageCode: getLanguageCode(locationCode),
+ scheduleInterval: schedule,
+ },
+ }),
+ onSuccess: () => {
+ captureClientEvent("rank_tracking:config_create");
+ toast.success("Domain added for rank tracking");
+ onSaved();
+ },
+ onError: (error) => {
+ toast.error(getStandardErrorMessage(error, "Failed to save config"));
+ },
+ });
+
+ const updateMutation = useMutation({
+ mutationFn: () =>
+ updateRankTrackingConfig({
+ data: {
+ projectId,
+ configId: existingConfig!.id,
+ domain,
+ devices,
+ locationCode,
+ languageCode: getLanguageCode(locationCode),
+ scheduleInterval: schedule,
+ },
+ }),
+ onSuccess: () => {
+ captureClientEvent("rank_tracking:config_update");
+ toast.success("Configuration updated");
+ onSaved();
+ },
+ onError: (error) => {
+ toast.error(getStandardErrorMessage(error, "Failed to update config"));
+ },
+ });
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (isPending) return;
+ if (!domain.trim()) {
+ toast.error("Please enter a domain");
+ return;
+ }
+ if (isEdit) {
+ updateMutation.mutate();
+ } else {
+ createMutation.mutate();
+ }
+ };
+
+ const isPending = createMutation.isPending || updateMutation.isPending;
+
+ return (
+
+
+
+ {isEdit ? "Edit Domain Config" : "Add Domain"}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/client/features/rank-tracking/RankTrackingDomainDetail.tsx b/src/client/features/rank-tracking/RankTrackingDomainDetail.tsx
new file mode 100644
index 0000000..5f3ac8b
--- /dev/null
+++ b/src/client/features/rank-tracking/RankTrackingDomainDetail.tsx
@@ -0,0 +1,330 @@
+import { useState } from "react";
+import { toast } from "sonner";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ getLatestRankResults,
+ estimateRankCheckCost,
+} from "@/serverFunctions/rank-tracking";
+import {
+ AlertTriangle,
+ ArrowLeft,
+ Loader2,
+ Plus,
+ Settings,
+ SlidersHorizontal,
+} from "lucide-react";
+import { captureClientEvent } from "@/client/lib/posthog";
+import { RankTrackingTable, exportRankTrackingCsv } from "./RankTrackingTable";
+import type { RankTrackingConfig } from "@/types/schemas/rank-tracking";
+import type { ComparePeriod } from "@/types/schemas/rank-tracking";
+import { LOCATIONS } from "@/client/features/keywords/locations";
+import { devicesLabel, scheduleLabel } from "@/shared/rank-tracking";
+import { ActionsMenu } from "./ActionsMenu";
+import { AddKeywordsPanel } from "./AddKeywordsPanel";
+import {
+ FilterPanel,
+ applyFilters,
+ countActiveFilters,
+ EMPTY_FILTERS,
+ type Filters,
+} from "./RankTrackingFilters";
+import { CheckConfirmModal } from "./CheckConfirmModal";
+import { useRankCheckTrigger } from "./useRankCheckTrigger";
+import { useRankRunPolling } from "./useRankRunPolling";
+import { useRankTableSort } from "./useRankTableSort";
+
+const COMPARE_PERIODS: ReadonlySet
= new Set([
+ "previous",
+ "7d",
+ "30d",
+ "90d",
+]);
+function isComparePeriod(v: string): v is ComparePeriod {
+ return COMPARE_PERIODS.has(v);
+}
+
+export function RankTrackingDomainDetail({
+ config,
+ projectId,
+ onBack,
+ onEdit,
+}: {
+ config: RankTrackingConfig;
+ projectId: string;
+ onBack: () => void;
+ onEdit: () => void;
+}) {
+ const queryClient = useQueryClient();
+ const [showAddKeywords, setShowAddKeywords] = useState(false);
+ const [showFilters, setShowFilters] = useState(false);
+ const [filters, setFilters] = useState(EMPTY_FILTERS);
+ const [comparePeriod, setComparePeriod] = useState("previous");
+
+ const { data: resultsData, isLoading: resultsLoading } = useQuery({
+ queryKey: ["rankTrackingResults", projectId, config.id, comparePeriod],
+ queryFn: () =>
+ getLatestRankResults({
+ data: { projectId, configId: config.id, comparePeriod },
+ }),
+ });
+
+ const latestRun = useRankRunPolling(projectId, config.id);
+
+ const { data: costEstimate } = useQuery({
+ queryKey: ["rankTrackingCostEstimate", projectId, config.id],
+ queryFn: () =>
+ estimateRankCheckCost({ data: { projectId, configId: config.id } }),
+ });
+
+ const [pendingCheck, setPendingCheck] = useState<{
+ count: number;
+ keywordIds?: string[];
+ } | null>(null);
+
+ const handleKeywordsAdded = (result: {
+ added: number;
+ addedIds?: string[];
+ }) => {
+ void queryClient.invalidateQueries({
+ queryKey: ["rankTrackingCostEstimate", projectId, config.id],
+ });
+ void queryClient.invalidateQueries({
+ queryKey: ["rankTrackingResults", projectId, config.id],
+ });
+ setShowAddKeywords(false);
+ captureClientEvent("rank_tracking:keywords_add");
+ toast.success(
+ `${result.added} keyword${result.added !== 1 ? "s" : ""} added`,
+ );
+ if (result.addedIds && result.addedIds.length > 0)
+ requestCheck(result.addedIds.length, result.addedIds);
+ };
+
+ const isRunning =
+ (latestRun?.status === "pending" || latestRun?.status === "running") &&
+ !latestRun?.maybeStale;
+ const { startCheck, isBusy, isPending } = useRankCheckTrigger({
+ configId: config.id,
+ isRunning,
+ projectId,
+ onSuccess: () => setPendingCheck(null),
+ });
+
+ const requestCheck = (count: number, keywordIds?: string[]) => {
+ if (count < 50) {
+ startCheck({ keywordIds });
+ return;
+ }
+
+ if (isBusy) return;
+ setPendingCheck({ count, keywordIds });
+ };
+
+ const rows = resultsData?.rows ?? [];
+ const run = resultsData?.run;
+ const showDesktop = config.devices !== "mobile";
+ const showMobile = config.devices !== "desktop";
+ const filtered = applyFilters(rows, filters);
+ const activeFilterCount = countActiveFilters(filters);
+ const defaultSort =
+ config.devices === "desktop" ? "desktopPosition" : "mobilePosition";
+ const { sorted, sortField, sortDir, handleSort } = useRankTableSort(
+ filtered,
+ defaultSort,
+ );
+
+ return (
+
+
+
+ Back to domains
+
+
+ {/* Domain header */}
+
+
+
{config.domain}
+
+ {LOCATIONS[config.locationCode] ?? "US"} ·{" "}
+ {devicesLabel(config.devices)} ·{" "}
+ {scheduleLabel(config.scheduleInterval)}
+ {run && (
+ <>
+ {" "}
+ · Last: {new Date(run.startedAt).toLocaleDateString()}
+ >
+ )}
+ {costEstimate && costEstimate.keywordCount > 0 && (
+ <> · ~${costEstimate.costUsd.toFixed(2)}/check>
+ )}
+
+
+
+
+
+ Configure
+
+
setShowAddKeywords(!showAddKeywords)}
+ >
+
+ Add Keywords
+
+
+
+
+ {config.lastSkipReason === "insufficient_credits" && (
+
+
+
+ Last scheduled check was skipped due to insufficient credits. Top up
+ your balance to resume automatic tracking.
+
+
+ )}
+
+ {latestRun?.maybeStale && (
+
+
+
+ This run may be unresponsive and will be cleaned up automatically.
+
+
+ )}
+
+ {showAddKeywords && (
+
setShowAddKeywords(false)}
+ />
+ )}
+
+ {/* Results card */}
+
+ {/* Table toolbar */}
+
+
setShowFilters((c) => !c)}
+ title="Toggle table filters"
+ >
+
+ Filters
+ {activeFilterCount > 0 && (
+
+ {activeFilterCount}
+
+ )}
+
+
{
+ if (isComparePeriod(e.target.value))
+ setComparePeriod(e.target.value);
+ }}
+ >
+ vs previous check
+ vs 7 days ago
+ vs 30 days ago
+ vs 90 days ago
+
+
+ {isRunning && latestRun ? (
+
+
+
+ {latestRun.status === "pending"
+ ? "Preparing..."
+ : "Checking keywords..."}{" "}
+ {latestRun.keywordsChecked}/{latestRun.keywordsTotal || "?"}
+
+ {latestRun.keywordsTotal > 0 && (
+
+ )}
+
+ ) : (
+
+ {filtered.length} keywords
+
+ )}
+
+
+
+
{
+ const count = costEstimate?.keywordCount ?? rows.length;
+ if (count > 0) requestCheck(count);
+ }}
+ onExport={() =>
+ exportRankTrackingCsv(
+ sorted,
+ showDesktop,
+ showMobile,
+ config.domain,
+ )
+ }
+ onCopyKeywords={() => {
+ const text = sorted.map((r) => r.keyword).join("\n");
+ void navigator.clipboard.writeText(text);
+ toast.success("Keywords copied to clipboard");
+ }}
+ isRunning={isBusy}
+ hasData={sorted.length > 0}
+ />
+
+
+ {/* Filters panel */}
+ {showFilters && (
+
setFilters(EMPTY_FILTERS)}
+ />
+ )}
+
+ {/* Table */}
+
+
+
+
+
+ {pendingCheck && (
+
+ startCheck({
+ keywordIds: pendingCheck.keywordIds,
+ })
+ }
+ onCancel={() => setPendingCheck(null)}
+ />
+ )}
+
+ );
+}
diff --git a/src/client/features/rank-tracking/RankTrackingDomainList.tsx b/src/client/features/rank-tracking/RankTrackingDomainList.tsx
new file mode 100644
index 0000000..6a98a56
--- /dev/null
+++ b/src/client/features/rank-tracking/RankTrackingDomainList.tsx
@@ -0,0 +1,106 @@
+import { useQuery } from "@tanstack/react-query";
+import { LOCATIONS } from "@/client/features/keywords/locations";
+import { AlertTriangle, Globe, Plus, ChevronRight } from "lucide-react";
+import { getRankTrackingConfigSummaries } from "@/serverFunctions/rank-tracking";
+import {
+ devicesLabel as getDevicesLabel,
+ scheduleLabel as getScheduleLabel,
+} from "@/shared/rank-tracking";
+
+type ConfigSummary = Awaited<
+ ReturnType
+>[number];
+
+export function RankTrackingDomainList({
+ projectId,
+ onSelectConfig,
+ onAddDomain,
+}: {
+ projectId: string;
+ onSelectConfig: (configId: string) => void;
+ onAddDomain: () => void;
+}) {
+ const { data: summaries } = useQuery({
+ queryKey: ["rankTrackingConfigSummaries", projectId],
+ queryFn: () => getRankTrackingConfigSummaries({ data: { projectId } }),
+ });
+
+ return (
+
+
+
+
Tracked Domains
+
+
+ Add Domain
+
+
+
+ {(summaries ?? []).map((summary) => (
+ onSelectConfig(summary.id)}
+ />
+ ))}
+
+
+
+ );
+}
+
+function DomainRow({
+ summary,
+ onClick,
+}: {
+ summary: ConfigSummary;
+ onClick: () => void;
+}) {
+ const dl = getDevicesLabel(summary.devices);
+ const sl = getScheduleLabel(summary.scheduleInterval);
+
+ return (
+
+
+
+
+
+
{summary.domain}
+
+ {LOCATIONS[summary.locationCode] ?? "US"} · {dl} · {sl}
+ {summary.lastRunCompletedAt && (
+ <>
+ {" "}
+ · Last:{" "}
+ {new Date(summary.lastRunCompletedAt).toLocaleDateString()}
+ >
+ )}
+
+ {summary.lastSkipReason === "insufficient_credits" && (
+
+
+ Scheduled check skipped — insufficient credits
+
+ )}
+
+
+ {summary.keywordCount > 0 && (
+
+
+ Keywords
+
+
{summary.keywordCount}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/client/features/rank-tracking/RankTrackingFilters.tsx b/src/client/features/rank-tracking/RankTrackingFilters.tsx
new file mode 100644
index 0000000..c254000
--- /dev/null
+++ b/src/client/features/rank-tracking/RankTrackingFilters.tsx
@@ -0,0 +1,193 @@
+import { RotateCcw } from "lucide-react";
+import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
+
+export type Filters = {
+ include: string;
+ exclude: string;
+ minDesktopPos: string;
+ maxDesktopPos: string;
+ minMobilePos: string;
+ maxMobilePos: string;
+};
+
+export const EMPTY_FILTERS: Filters = {
+ include: "",
+ exclude: "",
+ minDesktopPos: "",
+ maxDesktopPos: "",
+ minMobilePos: "",
+ maxMobilePos: "",
+};
+
+export function FilterPanel({
+ filters,
+ setFilters,
+ activeFilterCount,
+ onReset,
+}: {
+ filters: Filters;
+ setFilters: (f: Filters) => void;
+ activeFilterCount: number;
+ onReset: () => void;
+}) {
+ const update = (key: keyof Filters, value: string) =>
+ setFilters({ ...filters, [key]: value });
+
+ return (
+
+
+
+
Refine results
+ {activeFilterCount > 0 && (
+
+ {activeFilterCount} active
+
+ )}
+
+
+
+ Clear all
+
+
+
+
+
+ Include
+
+
update("include", e.target.value)}
+ />
+
+
+
+ Exclude
+
+
update("exclude", e.target.value)}
+ />
+
+
+
+ update("minDesktopPos", v)}
+ onMaxChange={(v) => update("maxDesktopPos", v)}
+ />
+ update("minMobilePos", v)}
+ onMaxChange={(v) => update("maxMobilePos", v)}
+ />
+
+
+ );
+}
+
+function RangeFilter({
+ title,
+ minValue,
+ maxValue,
+ onMinChange,
+ onMaxChange,
+}: {
+ title: string;
+ minValue: string;
+ maxValue: string;
+ onMinChange: (v: string) => void;
+ onMaxChange: (v: string) => void;
+}) {
+ return (
+
+ );
+}
+
+export function applyFilters(
+ rows: RankTrackingRow[],
+ filters: Filters,
+): RankTrackingRow[] {
+ return rows.filter((row) => {
+ const kw = row.keyword.toLowerCase();
+
+ if (filters.include) {
+ const terms = filters.include
+ .toLowerCase()
+ .split(",")
+ .map((t) => t.trim())
+ .filter(Boolean);
+ if (terms.length > 0 && !terms.some((t) => kw.includes(t))) return false;
+ }
+
+ if (filters.exclude) {
+ const terms = filters.exclude
+ .toLowerCase()
+ .split(",")
+ .map((t) => t.trim())
+ .filter(Boolean);
+ if (terms.some((t) => kw.includes(t))) return false;
+ }
+
+ if (filters.minDesktopPos || filters.maxDesktopPos) {
+ const min = filters.minDesktopPos ? Number(filters.minDesktopPos) : 0;
+ const max = filters.maxDesktopPos
+ ? Number(filters.maxDesktopPos)
+ : Infinity;
+ if (row.desktop.position === null) return false;
+ if (row.desktop.position < min || row.desktop.position > max)
+ return false;
+ }
+
+ if (filters.minMobilePos || filters.maxMobilePos) {
+ const min = filters.minMobilePos ? Number(filters.minMobilePos) : 0;
+ const max = filters.maxMobilePos
+ ? Number(filters.maxMobilePos)
+ : Infinity;
+ if (row.mobile.position === null) return false;
+ if (row.mobile.position < min || row.mobile.position > max) return false;
+ }
+
+ return true;
+ });
+}
+
+export function countActiveFilters(filters: Filters): number {
+ let count = 0;
+ if (filters.include) count++;
+ if (filters.exclude) count++;
+ if (filters.minDesktopPos || filters.maxDesktopPos) count++;
+ if (filters.minMobilePos || filters.maxMobilePos) count++;
+ return count;
+}
diff --git a/src/client/features/rank-tracking/RankTrackingPage.tsx b/src/client/features/rank-tracking/RankTrackingPage.tsx
new file mode 100644
index 0000000..108368d
--- /dev/null
+++ b/src/client/features/rank-tracking/RankTrackingPage.tsx
@@ -0,0 +1,107 @@
+import { useState } from "react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { Loader2, TrendingUp } from "lucide-react";
+import { getRankTrackingConfigs } from "@/serverFunctions/rank-tracking";
+import type { RankTrackingConfig } from "@/types/schemas/rank-tracking";
+import { RankTrackingDomainList } from "./RankTrackingDomainList";
+import { RankTrackingDomainDetail } from "./RankTrackingDomainDetail";
+import { RankTrackingConfigModal } from "./RankTrackingConfigModal";
+
+export function RankTrackingPage({ projectId }: { projectId: string }) {
+ const queryClient = useQueryClient();
+ const [selectedConfigId, setSelectedConfigId] = useState(null);
+ const [showConfigModal, setShowConfigModal] = useState(false);
+ const [editingConfig, setEditingConfig] = useState(
+ null,
+ );
+
+ const { data: configs, isLoading } = useQuery({
+ queryKey: ["rankTrackingConfigs", projectId],
+ queryFn: () => getRankTrackingConfigs({ data: { projectId } }),
+ });
+
+ const invalidateConfigs = () => {
+ void queryClient.invalidateQueries({
+ queryKey: ["rankTrackingConfigs", projectId],
+ });
+ void queryClient.invalidateQueries({
+ queryKey: ["rankTrackingConfigSummaries", projectId],
+ });
+ };
+
+ const selectedConfig =
+ configs?.find((c) => c.id === selectedConfigId) ?? null;
+
+ const openAddModal = () => {
+ setEditingConfig(null);
+ setShowConfigModal(true);
+ };
+
+ const openEditModal = (config: RankTrackingConfig) => {
+ setEditingConfig(config);
+ setShowConfigModal(true);
+ };
+
+ return (
+
+
+
+
Rank Tracking
+
+ Track keyword positions across domains
+
+
+
+ {isLoading ? (
+
+
+
+ ) : !configs || configs.length === 0 ? (
+
+
+
+
+
+ Track your keyword positions over time
+
+
+ Monitor rankings for your saved keywords, see position changes,
+ and spot trends across desktop and mobile.
+
+
+ Add Domain
+
+
+ Requires saved keywords in this project.
+
+
+ ) : selectedConfig ? (
+
setSelectedConfigId(null)}
+ onEdit={() => openEditModal(selectedConfig)}
+ />
+ ) : (
+
+ )}
+
+
+ {showConfigModal && (
+
setShowConfigModal(false)}
+ onSaved={() => {
+ setShowConfigModal(false);
+ invalidateConfigs();
+ }}
+ />
+ )}
+
+ );
+}
diff --git a/src/client/features/rank-tracking/RankTrackingTable.tsx b/src/client/features/rank-tracking/RankTrackingTable.tsx
new file mode 100644
index 0000000..cf8e379
--- /dev/null
+++ b/src/client/features/rank-tracking/RankTrackingTable.tsx
@@ -0,0 +1,245 @@
+import { useState } from "react";
+import { toast } from "sonner";
+import { Loader2, Trash2 } from "lucide-react";
+import { Modal } from "@/client/components/Modal";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { removeTrackingKeywords } from "@/serverFunctions/rank-tracking";
+import { getStandardErrorMessage } from "@/client/lib/error-messages";
+import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
+import {
+ SortableHeader,
+ DeviceRankCell,
+ type SortField,
+ type SortDir,
+} from "./RankTrackingTableParts";
+export {
+ comparePositions,
+ exportRankTrackingCsv,
+} from "./RankTrackingTableParts";
+export type { SortField, SortDir } from "./RankTrackingTableParts";
+
+export function RankTrackingTable({
+ totalCount,
+ sorted,
+ resultsLoading,
+ showDesktop,
+ showMobile,
+ sortField,
+ sortDir,
+ onSort,
+ domain,
+ configId,
+ projectId,
+}: {
+ totalCount: number;
+ sorted: RankTrackingRow[];
+ resultsLoading: boolean;
+ showDesktop: boolean;
+ showMobile: boolean;
+ sortField: SortField;
+ sortDir: SortDir;
+ onSort: (field: SortField) => void;
+ domain: string;
+ configId: string;
+ projectId: string;
+}) {
+ const queryClient = useQueryClient();
+ const [selected, setSelected] = useState>(new Set());
+ const [showConfirm, setShowConfirm] = useState(false);
+
+ // Only count/act on selections that are currently visible
+ const visibleIds = new Set(sorted.map((r) => r.trackingKeywordId));
+ const visibleSelected = new Set(
+ [...selected].filter((id) => visibleIds.has(id)),
+ );
+ const visibleSelectedCount = visibleSelected.size;
+
+ const removeMutation = useMutation({
+ mutationFn: (keywordIds: string[]) =>
+ removeTrackingKeywords({ data: { projectId, configId, keywordIds } }),
+ onSuccess: (result) => {
+ setSelected(new Set());
+ setShowConfirm(false);
+ void queryClient.invalidateQueries({
+ queryKey: ["rankTrackingResults", projectId, configId],
+ });
+ void queryClient.invalidateQueries({
+ queryKey: ["rankTrackingCostEstimate", projectId, configId],
+ });
+ toast.success(
+ `${result.removed} keyword${result.removed !== 1 ? "s" : ""} removed`,
+ );
+ },
+ onError: (error) => {
+ toast.error(getStandardErrorMessage(error, "Failed to remove keywords"));
+ },
+ });
+
+ const toggleSelect = (id: string) => {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ };
+
+ const toggleAll = () => {
+ if (visibleSelectedCount === sorted.length && sorted.length > 0) {
+ setSelected(new Set());
+ } else {
+ setSelected(new Set(sorted.map((r) => r.trackingKeywordId)));
+ }
+ };
+
+ if (resultsLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (sorted.length === 0) {
+ return (
+
+ {totalCount === 0
+ ? 'No rank data yet. Click "Check Now" to run your first check.'
+ : "No keywords match your search."}
+
+ );
+ }
+
+ const allSelected =
+ visibleSelectedCount === sorted.length && sorted.length > 0;
+
+ return (
+ <>
+ {/* Bulk action bar */}
+ {visibleSelectedCount > 0 && (
+
+
+ {visibleSelectedCount} keyword
+ {visibleSelectedCount !== 1 ? "s" : ""} selected
+
+ setShowConfirm(true)}
+ >
+
+ Remove
+
+ setSelected(new Set())}
+ >
+ Clear
+
+
+ )}
+
+ {/* Confirm modal */}
+ {showConfirm && (
+
+ Remove keywords?
+
+ This will stop tracking {visibleSelectedCount} keyword
+ {visibleSelectedCount !== 1 ? "s" : ""}. Historical ranking data is
+ preserved but won't appear in the table.
+
+
+ setShowConfirm(false)}
+ >
+ Cancel
+
+ removeMutation.mutate([...visibleSelected])}
+ disabled={removeMutation.isPending}
+ >
+ {removeMutation.isPending && (
+
+ )}
+ Remove {visibleSelectedCount} keyword
+ {visibleSelectedCount !== 1 ? "s" : ""}
+
+
+
+ )}
+
+
+
+ {sorted.length} of {totalCount} keywords
+
+ >
+ );
+}
diff --git a/src/client/features/rank-tracking/RankTrackingTableParts.tsx b/src/client/features/rank-tracking/RankTrackingTableParts.tsx
new file mode 100644
index 0000000..9361778
--- /dev/null
+++ b/src/client/features/rank-tracking/RankTrackingTableParts.tsx
@@ -0,0 +1,265 @@
+import { ArrowUp, ArrowDown, Minus, Sparkles } from "lucide-react";
+import { toast } from "sonner";
+import { buildCsv, downloadCsv } from "@/client/lib/csv";
+import { captureClientEvent } from "@/client/lib/posthog";
+import type {
+ RankTrackingDeviceResult,
+ RankTrackingRow,
+} from "@/types/schemas/rank-tracking";
+
+export type SortField = "keyword" | "desktopPosition" | "mobilePosition";
+export type SortDir = "asc" | "desc";
+
+const HEADER_TOOLTIPS: Record = {
+ keyword: "The search term being tracked",
+ desktopPosition: "Google ranking details on desktop devices",
+ mobilePosition: "Google ranking details on mobile devices",
+};
+
+export function SortableHeader({
+ label,
+ field,
+ currentField,
+ currentDir,
+ onClick,
+ className = "",
+}: {
+ label: string;
+ field: SortField;
+ currentField: SortField;
+ currentDir: SortDir;
+ onClick: (field: SortField) => void;
+ className?: string;
+}) {
+ const isActive = currentField === field;
+ return (
+ onClick(field)}
+ title={HEADER_TOOLTIPS[field]}
+ >
+ {label}
+ {isActive && (
+ {currentDir === "asc" ? "↑" : "↓"}
+ )}
+
+ );
+}
+
+function PositionBadge({ position }: { position: number | null }) {
+ if (position === null) {
+ return - ;
+ }
+ return {position} ;
+}
+
+function ChangeIndicator({
+ current,
+ previous,
+}: {
+ current: number | null;
+ previous: number | null;
+}) {
+ if (previous === null) {
+ return null;
+ }
+ if (current === null) {
+ return lost ;
+ }
+
+ const change = previous - current; // positive = improved (lower position number is better)
+ if (change > 0) {
+ return (
+
+ +{change}
+
+ );
+ }
+ if (change < 0) {
+ return (
+
+
+ {change}
+
+ );
+ }
+ return (
+
+
+
+ );
+}
+
+const FEATURE_SHORT_LABELS: Record = {
+ featured_snippet: "FS",
+ people_also_ask: "PAA",
+ ai_overview: "AI",
+ local_pack: "Local",
+ knowledge_panel: "KP",
+ video: "Video",
+ images: "Img",
+ shopping: "Shop",
+ top_stories: "News",
+};
+
+const FEATURE_TOOLTIPS: Record = {
+ featured_snippet:
+ "Featured Snippet — highlighted answer box at top of results",
+ people_also_ask: "People Also Ask — expandable related questions",
+ ai_overview: "AI Overview — AI-generated summary at top of search",
+ local_pack: "Local Pack — map with local business listings",
+ knowledge_panel: "Knowledge Panel — info box about an entity",
+ video: "Video — video results shown in the SERP",
+ images: "Images — image results shown in the SERP",
+ shopping: "Shopping — product listings with prices",
+ top_stories: "Top Stories — news articles carousel",
+};
+
+function SerpFeatureTags({ features }: { features: string[] }) {
+ const notable = features.filter((f) => f in FEATURE_SHORT_LABELS);
+ if (notable.length === 0) return null;
+ return (
+
+ {notable.map((f) => (
+
+ {f === "ai_overview" && }
+ {FEATURE_SHORT_LABELS[f]}
+
+ ))}
+
+ );
+}
+
+function PositionWithChange({
+ position,
+ previous,
+}: {
+ position: number | null;
+ previous: number | null;
+}) {
+ return (
+
+
+
+
+ );
+}
+
+export function DeviceRankCell({
+ result,
+ domain,
+}: {
+ result: RankTrackingDeviceResult;
+ domain: string;
+}) {
+ return (
+
+ );
+}
+
+export function comparePositions(a: number | null, b: number | null): number {
+ if (a === null && b === null) return 0;
+ if (a === null) return 1; // nulls sort last
+ if (b === null) return -1;
+ return a - b;
+}
+
+/** Numeric change for CSV export — numbers bypass the CSV formula-injection sanitizer */
+function csvChange(
+ current: number | null,
+ previous: number | null,
+): number | string {
+ if (previous === null) return current !== null ? "new" : "";
+ if (current === null) return "lost";
+ return previous - current;
+}
+
+export function exportRankTrackingCsv(
+ sorted: RankTrackingRow[],
+ showDesktop: boolean,
+ showMobile: boolean,
+ domain: string,
+) {
+ if (sorted.length === 0) {
+ toast.error("No data to export");
+ return;
+ }
+ const headers = [
+ "Keyword",
+ ...(showDesktop
+ ? [
+ "Desktop Position",
+ "Desktop Change",
+ "Desktop URL",
+ "Desktop SERP Features",
+ ]
+ : []),
+ ...(showMobile
+ ? [
+ "Mobile Position",
+ "Mobile Change",
+ "Mobile URL",
+ "Mobile SERP Features",
+ ]
+ : []),
+ ];
+ const csvRows = sorted.map((row) => [
+ row.keyword,
+ ...(showDesktop
+ ? [
+ row.desktop.position ?? "Not ranking",
+ csvChange(row.desktop.position, row.desktop.previousPosition),
+ row.desktop.rankingUrl ?? "",
+ row.desktop.serpFeatures.join(", "),
+ ]
+ : []),
+ ...(showMobile
+ ? [
+ row.mobile.position ?? "Not ranking",
+ csvChange(row.mobile.position, row.mobile.previousPosition),
+ row.mobile.rankingUrl ?? "",
+ row.mobile.serpFeatures.join(", "),
+ ]
+ : []),
+ ]);
+ downloadCsv(`rank-tracking-${domain}.csv`, buildCsv(headers, csvRows));
+ captureClientEvent("rank_tracking:export_csv");
+}
+
+function toPath(url: string): string {
+ try {
+ return new URL(url).pathname;
+ } catch {
+ return url;
+ }
+}
+
+function toFullUrl(url: string, domain: string): string {
+ if (url.startsWith("http")) return url;
+ return `https://${domain}${url}`;
+}
diff --git a/src/client/features/rank-tracking/useRankCheckTrigger.ts b/src/client/features/rank-tracking/useRankCheckTrigger.ts
new file mode 100644
index 0000000..1c641ab
--- /dev/null
+++ b/src/client/features/rank-tracking/useRankCheckTrigger.ts
@@ -0,0 +1,59 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { getStandardErrorMessage } from "@/client/lib/error-messages";
+import { captureClientEvent } from "@/client/lib/posthog";
+import { triggerRankCheck } from "@/serverFunctions/rank-tracking";
+
+export function useRankCheckTrigger({
+ configId,
+ isRunning,
+ projectId,
+ onSuccess,
+}: {
+ configId: string;
+ isRunning: boolean;
+ projectId: string;
+ onSuccess: () => void;
+}) {
+ const queryClient = useQueryClient();
+
+ const triggerMutation = useMutation({
+ mutationFn: (opts: { keywordIds?: string[] }) =>
+ triggerRankCheck({
+ data: {
+ projectId,
+ configId,
+ keywordIds: opts.keywordIds,
+ },
+ }),
+ onSuccess: (result) => {
+ onSuccess();
+ void queryClient.invalidateQueries({
+ queryKey: ["rankTrackingLatestRun", projectId, configId],
+ });
+ if (!result.ok) {
+ toast.info("A rank check is already running");
+ return;
+ }
+
+ captureClientEvent("rank_tracking:check_trigger");
+ toast.success("Rank check started");
+ },
+ onError: (error) => {
+ toast.error(getStandardErrorMessage(error, "Failed to start rank check"));
+ },
+ });
+
+ const startCheck = (opts: { keywordIds?: string[] }) => {
+ if (triggerMutation.isPending || isRunning) return;
+ triggerMutation.mutate(opts);
+ };
+
+ return {
+ startCheck,
+ /** True while the trigger request is in-flight */
+ isPending: triggerMutation.isPending,
+ /** True when any check activity is happening (running, starting, or pending) */
+ isBusy: isRunning || triggerMutation.isPending,
+ };
+}
diff --git a/src/client/features/rank-tracking/useRankRunPolling.ts b/src/client/features/rank-tracking/useRankRunPolling.ts
new file mode 100644
index 0000000..acdf0aa
--- /dev/null
+++ b/src/client/features/rank-tracking/useRankRunPolling.ts
@@ -0,0 +1,39 @@
+import { useRef } from "react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { getLatestRankRun } from "@/serverFunctions/rank-tracking";
+
+/**
+ * Polls the latest rank check run for a config, auto-refreshing results
+ * when a run transitions from "running" to "completed".
+ */
+export function useRankRunPolling(projectId: string, configId: string) {
+ const queryClient = useQueryClient();
+ const prevStatusRef = useRef(undefined);
+
+ const { data: latestRun } = useQuery({
+ queryKey: ["rankTrackingLatestRun", projectId, configId],
+ queryFn: () => getLatestRankRun({ data: { projectId, configId } }),
+ refetchInterval: (query) => {
+ const run = query.state.data;
+ const prev = prevStatusRef.current;
+ prevStatusRef.current = run?.status;
+
+ // When a run transitions to a terminal state, invalidate results
+ const isTerminal =
+ run?.status === "completed" || run?.status === "failed";
+ const wasActive = prev === "running" || prev === "pending";
+ if (wasActive && isTerminal) {
+ void queryClient.invalidateQueries({
+ queryKey: ["rankTrackingResults", projectId, configId],
+ });
+ }
+
+ // Keep polling active runs, including stale ones (they'll be cleaned up
+ // by the cron handler and we want to show the transition).
+ if (run?.status === "pending" || run?.status === "running") return 3000;
+ return false;
+ },
+ });
+
+ return latestRun;
+}
diff --git a/src/client/features/rank-tracking/useRankTableSort.ts b/src/client/features/rank-tracking/useRankTableSort.ts
new file mode 100644
index 0000000..d0b00df
--- /dev/null
+++ b/src/client/features/rank-tracking/useRankTableSort.ts
@@ -0,0 +1,37 @@
+import { useEffect, useState } from "react";
+import {
+ comparePositions,
+ type SortField,
+ type SortDir,
+} from "./RankTrackingTable";
+import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
+
+export function useRankTableSort(
+ rows: RankTrackingRow[],
+ defaultField: SortField,
+) {
+ const [sortField, setSortField] = useState(defaultField);
+ const [sortDir, setSortDir] = useState("asc");
+
+ useEffect(() => {
+ setSortField(defaultField);
+ }, [defaultField]);
+
+ const sorted = rows.toSorted((a, b) => {
+ const dir = sortDir === "asc" ? 1 : -1;
+ if (sortField === "keyword")
+ return dir * a.keyword.localeCompare(b.keyword);
+ const device = sortField === "desktopPosition" ? "desktop" : "mobile";
+ return dir * comparePositions(a[device].position, b[device].position);
+ });
+
+ const handleSort = (field: SortField) => {
+ if (sortField === field) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
+ else {
+ setSortField(field);
+ setSortDir("asc");
+ }
+ };
+
+ return { sorted, sortField, sortDir, handleSort };
+}
diff --git a/src/client/navigation/items.ts b/src/client/navigation/items.ts
index 229c7ab..09ff018 100644
--- a/src/client/navigation/items.ts
+++ b/src/client/navigation/items.ts
@@ -5,6 +5,7 @@ import {
Globe,
Link2,
Search,
+ TrendingUp,
} from "lucide-react";
import { linkOptions } from "@tanstack/react-router";
@@ -21,6 +22,12 @@ const projectNavItems = [
icon: Bookmark,
matchSegment: "/saved",
},
+ {
+ to: "/p/$projectId/rank-tracking" as const,
+ label: "Rank Tracking",
+ icon: TrendingUp,
+ matchSegment: "/rank-tracking",
+ },
{
to: "/p/$projectId/domain" as const,
label: "Domain Overview",
diff --git a/src/db/app.schema.ts b/src/db/app.schema.ts
index 654074f..97af351 100644
--- a/src/db/app.schema.ts
+++ b/src/db/app.schema.ts
@@ -100,6 +100,153 @@ export const keywordMetrics = sqliteTable(
],
);
+// ============================================================================
+// Rank Tracking tables
+// ============================================================================
+
+// One configuration per project+domain — defines what domain to track and how
+export const rankTrackingConfigs = sqliteTable(
+ "rank_tracking_configs",
+ {
+ id: text("id").primaryKey(),
+ projectId: text("project_id")
+ .notNull()
+ .references(() => projects.id, { onDelete: "cascade" }),
+ domain: text("domain").notNull(),
+ locationCode: integer("location_code").notNull().default(2840),
+ languageCode: text("language_code").notNull().default("en"),
+ devices: text("devices", {
+ enum: ["both", "desktop", "mobile"],
+ })
+ .notNull()
+ .default("both"),
+ scheduleInterval: text("schedule_interval", {
+ enum: ["daily", "weekly", "manual"],
+ })
+ .notNull()
+ .default("weekly"),
+ isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
+ lastCheckedAt: text("last_checked_at"),
+ nextCheckAt: text("next_check_at"),
+ lastSkipReason: text("last_skip_reason"),
+ createdAt: text("created_at")
+ .notNull()
+ .default(sql`(current_timestamp)`),
+ },
+ (table) => [
+ uniqueIndex("rank_tracking_configs_project_domain_location_idx").on(
+ table.projectId,
+ table.domain,
+ table.locationCode,
+ ),
+ ],
+);
+
+// Keywords tracked per domain config
+export const rankTrackingKeywords = sqliteTable(
+ "rank_tracking_keywords",
+ {
+ id: text("id").primaryKey(),
+ configId: text("config_id")
+ .notNull()
+ .references(() => rankTrackingConfigs.id, { onDelete: "cascade" }),
+ keyword: text("keyword").notNull(),
+ createdAt: text("created_at")
+ .notNull()
+ .default(sql`(current_timestamp)`),
+ },
+ (table) => [
+ uniqueIndex("rank_tracking_keywords_config_keyword_idx").on(
+ table.configId,
+ table.keyword,
+ ),
+ ],
+);
+
+// One row per check execution (manual or scheduled)
+export const rankCheckRuns = sqliteTable(
+ "rank_check_runs",
+ {
+ id: text("id").primaryKey(),
+ configId: text("config_id")
+ .notNull()
+ .references(() => rankTrackingConfigs.id, { onDelete: "cascade" }),
+ projectId: text("project_id")
+ .notNull()
+ .references(() => projects.id, { onDelete: "cascade" }),
+ status: text("status", {
+ enum: ["pending", "running", "completed", "failed"],
+ })
+ .notNull()
+ .default("pending"),
+ keywordsTotal: integer("keywords_total").notNull().default(0),
+ keywordsChecked: integer("keywords_checked").notNull().default(0),
+ isSubsetRun: integer("is_subset_run", { mode: "boolean" })
+ .notNull()
+ .default(false),
+ errorMessage: text("error_message"),
+ startedAt: text("started_at")
+ .notNull()
+ .default(sql`(current_timestamp)`),
+ completedAt: text("completed_at"),
+ },
+ (table) => [
+ index("rank_check_runs_config_idx").on(table.configId, table.startedAt),
+ index("rank_check_runs_project_idx").on(table.projectId, table.startedAt),
+ ],
+);
+
+// One active lock per rank tracking config to prevent overlapping runs
+export const rankCheckLocks = sqliteTable(
+ "rank_check_locks",
+ {
+ configId: text("config_id")
+ .primaryKey()
+ .references(() => rankTrackingConfigs.id, { onDelete: "cascade" }),
+ runId: text("run_id").notNull(),
+ acquiredAt: text("acquired_at")
+ .notNull()
+ .default(sql`(current_timestamp)`),
+ },
+ (table) => [index("rank_check_locks_run_idx").on(table.runId)],
+);
+
+// One row per keyword per device per check run
+export const rankSnapshots = sqliteTable(
+ "rank_snapshots",
+ {
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ runId: text("run_id")
+ .notNull()
+ .references(() => rankCheckRuns.id, { onDelete: "cascade" }),
+ // No FK to rankTrackingKeywords — intentional. Historical snapshots are
+ // preserved after a keyword is removed from tracking so users can still
+ // see past position data for deleted keywords.
+ trackingKeywordId: text("tracking_keyword_id").notNull(),
+ keyword: text("keyword").notNull(),
+ device: text("device", { enum: ["desktop", "mobile"] }).notNull(),
+ position: integer("position"), // null = not found in top 20
+ url: text("url"),
+ serpFeatures: text("serp_features"), // JSON array of feature type strings
+ checkedAt: text("checked_at")
+ .notNull()
+ .default(sql`(current_timestamp)`),
+ },
+ (table) => [
+ index("rank_snapshots_run_idx").on(table.runId),
+ index("rank_snapshots_keyword_device_idx").on(
+ table.trackingKeywordId,
+ table.device,
+ table.checkedAt,
+ ),
+ uniqueIndex("rank_snapshots_run_keyword_device_idx").on(
+ table.runId,
+ table.trackingKeywordId,
+ table.device,
+ ),
+ ],
+);
+
// ============================================================================
// Site Audit tables
// ============================================================================
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index 1797ac9..25c457f 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -28,6 +28,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 ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved'
+import { Route as ProjectPProjectIdRankTrackingRouteImport } from './routes/_project/p/$projectId/rank-tracking'
import { Route as ProjectPProjectIdKeywordsRouteImport } from './routes/_project/p/$projectId/keywords'
import { Route as ProjectPProjectIdDomainRouteImport } from './routes/_project/p/$projectId/domain'
import { Route as ProjectPProjectIdBacklinksRouteImport } from './routes/_project/p/$projectId/backlinks'
@@ -127,6 +128,12 @@ const ProjectPProjectIdSavedRoute = ProjectPProjectIdSavedRouteImport.update({
path: '/saved',
getParentRoute: () => ProjectPProjectIdRouteRoute,
} as any)
+const ProjectPProjectIdRankTrackingRoute =
+ ProjectPProjectIdRankTrackingRouteImport.update({
+ id: '/rank-tracking',
+ path: '/rank-tracking',
+ getParentRoute: () => ProjectPProjectIdRouteRoute,
+ } as any)
const ProjectPProjectIdKeywordsRoute =
ProjectPProjectIdKeywordsRouteImport.update({
id: '/keywords',
@@ -186,6 +193,7 @@ export interface FileRoutesByFullPath {
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
+ '/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRoute
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
'/p/$projectId/': typeof ProjectPProjectIdIndexRoute
'/p/$projectId/audit/': typeof ProjectPProjectIdAuditIndexRoute
@@ -208,6 +216,7 @@ export interface FileRoutesByTo {
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
+ '/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRoute
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
'/p/$projectId': typeof ProjectPProjectIdIndexRoute
'/p/$projectId/audit': typeof ProjectPProjectIdAuditIndexRoute
@@ -237,6 +246,7 @@ export interface FileRoutesById {
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
'/_project/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
'/_project/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
+ '/_project/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRoute
'/_project/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
'/_project/p/$projectId/': typeof ProjectPProjectIdIndexRoute
'/_project/p/$projectId/audit/': typeof ProjectPProjectIdAuditIndexRoute
@@ -263,6 +273,7 @@ export interface FileRouteTypes {
| '/p/$projectId/backlinks'
| '/p/$projectId/domain'
| '/p/$projectId/keywords'
+ | '/p/$projectId/rank-tracking'
| '/p/$projectId/saved'
| '/p/$projectId/'
| '/p/$projectId/audit/'
@@ -285,6 +296,7 @@ export interface FileRouteTypes {
| '/p/$projectId/backlinks'
| '/p/$projectId/domain'
| '/p/$projectId/keywords'
+ | '/p/$projectId/rank-tracking'
| '/p/$projectId/saved'
| '/p/$projectId'
| '/p/$projectId/audit'
@@ -313,6 +325,7 @@ export interface FileRouteTypes {
| '/_project/p/$projectId/backlinks'
| '/_project/p/$projectId/domain'
| '/_project/p/$projectId/keywords'
+ | '/_project/p/$projectId/rank-tracking'
| '/_project/p/$projectId/saved'
| '/_project/p/$projectId/'
| '/_project/p/$projectId/audit/'
@@ -466,6 +479,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ProjectPProjectIdSavedRouteImport
parentRoute: typeof ProjectPProjectIdRouteRoute
}
+ '/_project/p/$projectId/rank-tracking': {
+ id: '/_project/p/$projectId/rank-tracking'
+ path: '/rank-tracking'
+ fullPath: '/p/$projectId/rank-tracking'
+ preLoaderRoute: typeof ProjectPProjectIdRankTrackingRouteImport
+ parentRoute: typeof ProjectPProjectIdRouteRoute
+ }
'/_project/p/$projectId/keywords': {
id: '/_project/p/$projectId/keywords'
path: '/keywords'
@@ -559,6 +579,7 @@ interface ProjectPProjectIdRouteRouteChildren {
ProjectPProjectIdBacklinksRoute: typeof ProjectPProjectIdBacklinksRoute
ProjectPProjectIdDomainRoute: typeof ProjectPProjectIdDomainRoute
ProjectPProjectIdKeywordsRoute: typeof ProjectPProjectIdKeywordsRoute
+ ProjectPProjectIdRankTrackingRoute: typeof ProjectPProjectIdRankTrackingRoute
ProjectPProjectIdSavedRoute: typeof ProjectPProjectIdSavedRoute
ProjectPProjectIdIndexRoute: typeof ProjectPProjectIdIndexRoute
}
@@ -570,6 +591,7 @@ const ProjectPProjectIdRouteRouteChildren: ProjectPProjectIdRouteRouteChildren =
ProjectPProjectIdBacklinksRoute: ProjectPProjectIdBacklinksRoute,
ProjectPProjectIdDomainRoute: ProjectPProjectIdDomainRoute,
ProjectPProjectIdKeywordsRoute: ProjectPProjectIdKeywordsRoute,
+ ProjectPProjectIdRankTrackingRoute: ProjectPProjectIdRankTrackingRoute,
ProjectPProjectIdSavedRoute: ProjectPProjectIdSavedRoute,
ProjectPProjectIdIndexRoute: ProjectPProjectIdIndexRoute,
}
diff --git a/src/routes/_project/p/$projectId/rank-tracking.tsx b/src/routes/_project/p/$projectId/rank-tracking.tsx
new file mode 100644
index 0000000..17c65f5
--- /dev/null
+++ b/src/routes/_project/p/$projectId/rank-tracking.tsx
@@ -0,0 +1,11 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { RankTrackingPage } from "@/client/features/rank-tracking/RankTrackingPage";
+
+export const Route = createFileRoute("/_project/p/$projectId/rank-tracking")({
+ component: RankTrackingRoute,
+});
+
+function RankTrackingRoute() {
+ const { projectId } = Route.useParams();
+ return ;
+}
diff --git a/src/routes/_project/p/$projectId/saved.tsx b/src/routes/_project/p/$projectId/saved.tsx
index f835386..11cb57f 100644
--- a/src/routes/_project/p/$projectId/saved.tsx
+++ b/src/routes/_project/p/$projectId/saved.tsx
@@ -6,7 +6,14 @@ import {
getSavedKeywords,
removeSavedKeyword,
} from "@/serverFunctions/keywords";
-import { Trash2, Download, Search, Loader2, AlertCircle } from "lucide-react";
+import {
+ Download,
+ Search,
+ Loader2,
+ AlertCircle,
+ Trash2,
+ Copy,
+} from "lucide-react";
import { buildCsv, downloadCsv } from "@/client/lib/csv";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog";
@@ -15,17 +22,30 @@ export const Route = createFileRoute("/_project/p/$projectId/saved")({
component: SavedKeywordsPage,
});
+type SavedKeyword = {
+ id: string;
+ keyword: string;
+ searchVolume: number | null;
+ cpc: number | null;
+ competition: number | null;
+ keywordDifficulty: number | null;
+ intent: string | null;
+ fetchedAt: string | null;
+};
+
function SavedKeywordsPage() {
const { projectId } = Route.useParams();
const queryClient = useQueryClient();
const [removeError, setRemoveError] = useState(null);
- const [removingId, setRemovingId] = useState(null);
+ const [selected, setSelected] = useState>(new Set());
+ const [showConfirm, setShowConfirm] = useState(false);
+ const [deleting, setDeleting] = useState(false);
const { data: savedKeywordsData, isLoading } = useQuery({
queryKey: ["savedKeywords", projectId],
queryFn: () => getSavedKeywords({ data: { projectId } }),
});
- const savedKeywords = savedKeywordsData?.rows ?? [];
+ const savedKeywords: SavedKeyword[] = savedKeywordsData?.rows ?? [];
const removeMutation = useMutation({
mutationFn: (savedKeywordId: string) =>
@@ -42,16 +62,54 @@ function SavedKeywordsPage() {
},
});
- const handleRemoveKeyword = (savedKeywordId: string) => {
+ const handleDeleteSelected = async () => {
+ setDeleting(true);
setRemoveError(null);
- setRemovingId(savedKeywordId);
- removeMutation.mutate(savedKeywordId, {
- onSettled: () => {
- setRemovingId((current) =>
- current === savedKeywordId ? null : current,
- );
- },
+ const ids = [...selected];
+ for (const id of ids) {
+ try {
+ await removeMutation.mutateAsync(id);
+ } catch {
+ break;
+ }
+ }
+ setDeleting(false);
+ setSelected(new Set());
+ setShowConfirm(false);
+ void queryClient.invalidateQueries({
+ queryKey: ["savedKeywords", projectId],
});
+ captureClientEvent("saved_keywords:bulk_remove");
+ toast.success(
+ `${ids.length} keyword${ids.length !== 1 ? "s" : ""} removed`,
+ );
+ };
+
+ const handleCopySelected = () => {
+ const keywords = savedKeywords
+ .filter((kw) => selected.has(kw.id))
+ .map((kw) => kw.keyword);
+ void navigator.clipboard.writeText(keywords.join("\n"));
+ toast.success(
+ `${keywords.length} keyword${keywords.length !== 1 ? "s" : ""} copied`,
+ );
+ };
+
+ const toggleSelect = (id: string) => {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ };
+
+ const toggleAll = () => {
+ if (selected.size === savedKeywords.length) {
+ setSelected(new Set());
+ } else {
+ setSelected(new Set(savedKeywords.map((kw) => kw.id)));
+ }
};
const exportCsv = () => {
@@ -59,7 +117,6 @@ function SavedKeywordsPage() {
toast.error("No keywords to export");
return;
}
-
const headers = [
"Keyword",
"Volume",
@@ -86,42 +143,6 @@ function SavedKeywordsPage() {
});
};
- return (
-
- );
-}
-
-function SavedKeywordsContent({
- isLoading,
- removeError,
- removingId,
- savedKeywords,
- onExportCsv,
- onRemoveKeyword,
-}: {
- isLoading: boolean;
- removeError: string | null;
- removingId: string | null;
- savedKeywords: Array<{
- id: string;
- keyword: string;
- searchVolume: number | null;
- cpc: number | null;
- competition: number | null;
- keywordDifficulty: number | null;
- intent: string | null;
- fetchedAt: string | null;
- }>;
- onExportCsv: () => void;
- onRemoveKeyword: (savedKeywordId: string) => void;
-}) {
return (
@@ -133,7 +154,7 @@ function SavedKeywordsContent({
{savedKeywords.length > 0 && (
-
+
Export CSV
)}
@@ -178,15 +199,137 @@ function SavedKeywordsContent({
{removeError}
) : null}
-
- {savedKeywords.length} saved keyword
- {savedKeywords.length !== 1 ? "s" : ""}
-
-
+
+ {/* Bulk action bar or keyword count */}
+ {selected.size > 0 ? (
+
+
+ {selected.size} keyword
+ {selected.size !== 1 ? "s" : ""} selected
+
+
+
+ Copy
+
+ setShowConfirm(true)}
+ >
+
+ Delete
+
+ setSelected(new Set())}
+ >
+ Clear
+
+
+ ) : (
+
+ {savedKeywords.length} saved keyword
+ {savedKeywords.length !== 1 ? "s" : ""}
+
+ )}
+
+
+
+
+ )}
+
+ {/* Confirm delete modal */}
+ {showConfirm && (
+
+
+
+
Delete keywords?
+
+ This will permanently delete {selected.size} saved keyword
+ {selected.size !== 1 ? "s" : ""}.
+
+
+ setShowConfirm(false)}
+ >
+ Cancel
+
+ void handleDeleteSelected()}
+ disabled={deleting}
+ >
+ {deleting && }
+ Delete {selected.size} keyword
+ {selected.size !== 1 ? "s" : ""}
+
+
+
)}
@@ -195,83 +338,6 @@ function SavedKeywordsContent({
);
}
-function SavedKeywordsTable({
- rows,
- removingId,
- onRemoveKeyword,
-}: {
- rows: Array<{
- id: string;
- keyword: string;
- searchVolume: number | null;
- cpc: number | null;
- competition: number | null;
- keywordDifficulty: number | null;
- intent: string | null;
- fetchedAt: string | null;
- }>;
- removingId: string | null;
- onRemoveKeyword: (savedKeywordId: string) => void;
-}) {
- return (
-
-
-
-
- Keyword
- Volume
- CPC
- Competition
- Difficulty
- Intent
- Last Fetched
-
-
-
-
- {rows.map((kw) => (
-
- {kw.keyword}
- {formatNumber(kw.searchVolume)}
- {kw.cpc == null ? "-" : `$${kw.cpc.toFixed(2)}`}
-
- {kw.competition == null ? "-" : kw.competition.toFixed(2)}
-
-
-
-
-
-
- {kw.intent ?? "?"}
-
-
-
- {kw.fetchedAt
- ? new Date(kw.fetchedAt).toLocaleDateString()
- : "-"}
-
-
- onRemoveKeyword(kw.id)}
- disabled={removingId === kw.id}
- title="Remove"
- >
- {removingId === kw.id ? (
-
- ) : (
-
- )}
-
-
-
- ))}
-
-
-
- );
-}
-
function DifficultyBadge({ value }: { value: number | null }) {
if (value == null)
return - ;
diff --git a/src/server.ts b/src/server.ts
index c378cfb..b61ba2c 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -2,12 +2,119 @@ import {
createStartHandler,
defaultStreamHandler,
} from "@tanstack/react-start/server";
+import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
+import { beginRankCheckRun } from "@/server/features/rank-tracking/services/rankCheckRunGuards";
+import { customerHasManagedServiceAccess } from "@/server/billing/subscription";
+import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
+import { computeNextCheckAt } from "@/shared/rank-tracking";
const fetch = createStartHandler(defaultStreamHandler);
// Export Workflow classes as named exports
export { SiteAuditWorkflow } from "./server/workflows/SiteAuditWorkflow";
+export { RankCheckWorkflow } from "./server/workflows/RankCheckWorkflow";
export default {
fetch,
+ async scheduled(
+ _controller: ScheduledController,
+ env: Env,
+ _ctx: ExecutionContext,
+ ) {
+ const nowIso = new Date().toISOString();
+ const dueConfigs =
+ await RankTrackingRepository.getDueConfigsWithOrganization(nowIso);
+
+ const isHosted = await isHostedServerAuthMode();
+
+ for (const config of dueConfigs) {
+ try {
+ // Skip configs whose org no longer has paid access
+ if (
+ isHosted &&
+ !(await customerHasManagedServiceAccess(config.organizationId))
+ ) {
+ console.log(
+ `[cron] Skipping config ${config.id} (${config.domain}) — org ${config.organizationId} no longer has access`,
+ );
+ continue;
+ }
+
+ // Skip configs with no keywords before advancing the schedule
+ const kwCount = await RankTrackingRepository.getKeywordCountForConfig(
+ config.id,
+ );
+ if (kwCount === 0) {
+ console.log(
+ `[cron] Skipping config ${config.id} (${config.domain}) — no keywords`,
+ );
+ // Still advance schedule so this config doesn't stay due forever
+ const skipInterval =
+ config.scheduleInterval === "daily" ||
+ config.scheduleInterval === "weekly"
+ ? config.scheduleInterval
+ : null;
+ if (skipInterval) {
+ await RankTrackingRepository.updateConfig(
+ config.id,
+ config.projectId,
+ {
+ nextCheckAt: computeNextCheckAt(
+ skipInterval,
+ config.nextCheckAt,
+ ),
+ },
+ );
+ }
+ continue;
+ }
+
+ // Advance nextCheckAt immediately to prevent retry storms if the run fails
+ const interval =
+ config.scheduleInterval === "daily" ||
+ config.scheduleInterval === "weekly"
+ ? config.scheduleInterval
+ : null;
+ if (interval) {
+ await RankTrackingRepository.updateConfig(
+ config.id,
+ config.projectId,
+ {
+ nextCheckAt: computeNextCheckAt(interval, config.nextCheckAt),
+ },
+ );
+ }
+
+ const result = await beginRankCheckRun({
+ workflow: env.RANK_CHECK_WORKFLOW,
+ config,
+ projectId: config.projectId,
+ billingCustomer: {
+ userId: "system",
+ userEmail: "system@openseo.so",
+ organizationId: config.organizationId,
+ projectId: config.projectId,
+ },
+ keywordsTotal: kwCount,
+ trigger: "scheduled",
+ workflowStartErrorMessage: "Failed to start scheduled workflow",
+ });
+
+ if (!result.ok) {
+ console.log(
+ `[cron] Skipping config ${config.id} (${config.domain}) — run already active`,
+ );
+ } else {
+ console.log(
+ `[cron] Started scheduled rank check ${result.runId} for config ${config.id} (${config.domain})`,
+ );
+ }
+ } catch (err) {
+ console.error(
+ `[cron] Error processing config ${config.id} (${config.domain}):`,
+ err,
+ );
+ }
+ }
+ },
};
diff --git a/src/server/features/rank-tracking/repositories/RankTrackingRepository.ts b/src/server/features/rank-tracking/repositories/RankTrackingRepository.ts
new file mode 100644
index 0000000..d51ca76
--- /dev/null
+++ b/src/server/features/rank-tracking/repositories/RankTrackingRepository.ts
@@ -0,0 +1,411 @@
+import { and, asc, count, desc, eq, gte, inArray, lte, max } from "drizzle-orm";
+import type { InferInsertModel } from "drizzle-orm";
+import { db } from "@/db";
+import {
+ rankTrackingConfigs,
+ rankCheckRuns,
+ rankCheckLocks,
+ rankSnapshots,
+ rankTrackingKeywords,
+ projects,
+} from "@/db/schema";
+
+const DB_BATCH_SIZE = 100;
+type BatchStatement = Parameters[0][number];
+
+async function executeInBatches(
+ items: T[],
+ buildStatement: (item: T) => BatchStatement,
+) {
+ for (let i = 0; i < items.length; i += DB_BATCH_SIZE) {
+ const chunk = items.slice(i, i + DB_BATCH_SIZE).map(buildStatement);
+ const [first, ...rest] = chunk;
+ if (!first) continue;
+ await db.batch([first, ...rest]);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Config CRUD
+// ---------------------------------------------------------------------------
+
+async function getConfigsForProject(projectId: string) {
+ return db
+ .select()
+ .from(rankTrackingConfigs)
+ .where(eq(rankTrackingConfigs.projectId, projectId))
+ .orderBy(rankTrackingConfigs.createdAt);
+}
+
+async function getConfigById({
+ configId,
+ projectId,
+}: {
+ configId: string;
+ projectId: string;
+}) {
+ const rows = await db
+ .select()
+ .from(rankTrackingConfigs)
+ .where(
+ and(
+ eq(rankTrackingConfigs.id, configId),
+ eq(rankTrackingConfigs.projectId, projectId),
+ ),
+ )
+ .limit(1);
+ return rows[0] ?? null;
+}
+
+async function getConfigByProjectDomainLocation(
+ projectId: string,
+ domain: string,
+ locationCode: number,
+) {
+ const rows = await db
+ .select()
+ .from(rankTrackingConfigs)
+ .where(
+ and(
+ eq(rankTrackingConfigs.projectId, projectId),
+ eq(rankTrackingConfigs.domain, domain),
+ eq(rankTrackingConfigs.locationCode, locationCode),
+ ),
+ )
+ .limit(1);
+ return rows[0] ?? null;
+}
+
+async function createConfig(
+ data: InferInsertModel,
+) {
+ await db.insert(rankTrackingConfigs).values(data);
+}
+
+async function updateConfig(
+ configId: string,
+ projectId: string,
+ data: Partial>,
+) {
+ await db
+ .update(rankTrackingConfigs)
+ .set(data)
+ .where(
+ and(
+ eq(rankTrackingConfigs.id, configId),
+ eq(rankTrackingConfigs.projectId, projectId),
+ ),
+ );
+}
+
+async function getDueConfigsWithOrganization(nowIso: string) {
+ return db
+ .select({
+ id: rankTrackingConfigs.id,
+ projectId: rankTrackingConfigs.projectId,
+ domain: rankTrackingConfigs.domain,
+ locationCode: rankTrackingConfigs.locationCode,
+ languageCode: rankTrackingConfigs.languageCode,
+ devices: rankTrackingConfigs.devices,
+ scheduleInterval: rankTrackingConfigs.scheduleInterval,
+ nextCheckAt: rankTrackingConfigs.nextCheckAt,
+ organizationId: projects.organizationId,
+ })
+ .from(rankTrackingConfigs)
+ .innerJoin(projects, eq(rankTrackingConfigs.projectId, projects.id))
+ .where(
+ and(
+ eq(rankTrackingConfigs.isActive, true),
+ lte(rankTrackingConfigs.nextCheckAt, nowIso),
+ ),
+ )
+ .limit(50);
+}
+
+// ---------------------------------------------------------------------------
+// Run CRUD
+// ---------------------------------------------------------------------------
+
+async function createRun(data: {
+ id: string;
+ configId: string;
+ projectId: string;
+ keywordsTotal: number;
+ isSubsetRun?: boolean;
+}) {
+ await db.insert(rankCheckRuns).values({
+ ...data,
+ status: "pending",
+ });
+}
+
+async function updateRun(
+ runId: string,
+ data: Partial>,
+) {
+ await db.update(rankCheckRuns).set(data).where(eq(rankCheckRuns.id, runId));
+}
+
+async function getRunById(runId: string) {
+ const rows = await db
+ .select()
+ .from(rankCheckRuns)
+ .where(eq(rankCheckRuns.id, runId))
+ .limit(1);
+ return rows[0] ?? null;
+}
+
+async function getLatestRunForConfig(configId: string) {
+ const rows = await db
+ .select()
+ .from(rankCheckRuns)
+ .where(eq(rankCheckRuns.configId, configId))
+ .orderBy(desc(rankCheckRuns.startedAt))
+ .limit(1);
+ return rows[0] ?? null;
+}
+
+async function tryCreateRunLock(configId: string, runId: string) {
+ const inserted = await db
+ .insert(rankCheckLocks)
+ .values({ configId, runId })
+ .onConflictDoNothing({ target: rankCheckLocks.configId })
+ .returning({ runId: rankCheckLocks.runId });
+
+ return inserted.length > 0;
+}
+
+async function getRunLock(configId: string) {
+ const rows = await db
+ .select()
+ .from(rankCheckLocks)
+ .where(eq(rankCheckLocks.configId, configId))
+ .limit(1);
+ return rows[0] ?? null;
+}
+
+async function deleteRunLock(configId: string, runId?: string) {
+ await db
+ .delete(rankCheckLocks)
+ .where(
+ runId
+ ? and(
+ eq(rankCheckLocks.configId, configId),
+ eq(rankCheckLocks.runId, runId),
+ )
+ : eq(rankCheckLocks.configId, configId),
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Snapshots
+// ---------------------------------------------------------------------------
+
+async function insertSnapshots(
+ snapshots: Array<
+ Omit, "id" | "checkedAt">
+ >,
+) {
+ await executeInBatches(snapshots, (snapshot) =>
+ db.insert(rankSnapshots).values(snapshot).onConflictDoNothing(),
+ );
+}
+
+async function getSnapshotsForRun(runId: string) {
+ return db.select().from(rankSnapshots).where(eq(rankSnapshots.runId, runId));
+}
+
+async function getRecentCompletedRuns(configId: string, limit: number) {
+ return db
+ .select()
+ .from(rankCheckRuns)
+ .where(
+ and(
+ eq(rankCheckRuns.configId, configId),
+ eq(rankCheckRuns.status, "completed"),
+ eq(rankCheckRuns.isSubsetRun, false),
+ ),
+ )
+ .orderBy(desc(rankCheckRuns.startedAt))
+ .limit(limit);
+}
+
+async function getClosestCompletedRun(configId: string, targetDate: string) {
+ const [beforeRows, afterRows] = await Promise.all([
+ db
+ .select()
+ .from(rankCheckRuns)
+ .where(
+ and(
+ eq(rankCheckRuns.configId, configId),
+ eq(rankCheckRuns.status, "completed"),
+ eq(rankCheckRuns.isSubsetRun, false),
+ lte(rankCheckRuns.startedAt, targetDate),
+ ),
+ )
+ .orderBy(desc(rankCheckRuns.startedAt))
+ .limit(1),
+ db
+ .select()
+ .from(rankCheckRuns)
+ .where(
+ and(
+ eq(rankCheckRuns.configId, configId),
+ eq(rankCheckRuns.status, "completed"),
+ eq(rankCheckRuns.isSubsetRun, false),
+ gte(rankCheckRuns.startedAt, targetDate),
+ ),
+ )
+ .orderBy(asc(rankCheckRuns.startedAt))
+ .limit(1),
+ ]);
+
+ const before = beforeRows[0] ?? null;
+ const after = afterRows[0] ?? null;
+ if (!before) return after;
+ if (!after) return before;
+
+ const targetMs = new Date(targetDate).getTime();
+ const beforeDiff = Math.abs(targetMs - new Date(before.startedAt).getTime());
+ const afterDiff = Math.abs(new Date(after.startedAt).getTime() - targetMs);
+ return beforeDiff <= afterDiff ? before : after;
+}
+
+// ---------------------------------------------------------------------------
+// Tracking keywords per config
+// ---------------------------------------------------------------------------
+
+async function getKeywordsForConfig(configId: string) {
+ return db
+ .select()
+ .from(rankTrackingKeywords)
+ .where(eq(rankTrackingKeywords.configId, configId))
+ .orderBy(rankTrackingKeywords.createdAt);
+}
+
+async function addKeywordsToConfig(
+ keywords: Array<{ id: string; configId: string; keyword: string }>,
+) {
+ await executeInBatches(keywords, (kw) =>
+ db.insert(rankTrackingKeywords).values(kw).onConflictDoNothing(),
+ );
+}
+
+async function removeKeywordsFromConfig(
+ keywordIds: string[],
+ configId: string,
+) {
+ await db
+ .delete(rankTrackingKeywords)
+ .where(
+ and(
+ inArray(rankTrackingKeywords.id, keywordIds),
+ eq(rankTrackingKeywords.configId, configId),
+ ),
+ );
+}
+
+async function getConfigSummaries(projectId: string) {
+ const configs = await getConfigsForProject(projectId);
+ if (configs.length === 0) return [];
+
+ // Batch: keyword counts grouped by config
+ const kwCounts = await db
+ .select({
+ configId: rankTrackingKeywords.configId,
+ value: count(),
+ })
+ .from(rankTrackingKeywords)
+ .where(
+ inArray(
+ rankTrackingKeywords.configId,
+ configs.map((c) => c.id),
+ ),
+ )
+ .groupBy(rankTrackingKeywords.configId);
+
+ const kwCountMap = new Map(kwCounts.map((r) => [r.configId, r.value]));
+
+ // Subquery: latest startedAt per config
+ const latestStarted = db
+ .select({
+ configId: rankCheckRuns.configId,
+ maxStartedAt: max(rankCheckRuns.startedAt).as("maxStartedAt"),
+ })
+ .from(rankCheckRuns)
+ .where(
+ inArray(
+ rankCheckRuns.configId,
+ configs.map((c) => c.id),
+ ),
+ )
+ .groupBy(rankCheckRuns.configId)
+ .as("latestStarted");
+
+ // Join back to get status + completedAt for each config's latest run
+ const latestRuns = await db
+ .select({
+ configId: rankCheckRuns.configId,
+ status: rankCheckRuns.status,
+ completedAt: rankCheckRuns.completedAt,
+ })
+ .from(rankCheckRuns)
+ .innerJoin(
+ latestStarted,
+ and(
+ eq(rankCheckRuns.configId, latestStarted.configId),
+ eq(rankCheckRuns.startedAt, latestStarted.maxStartedAt),
+ ),
+ );
+
+ const latestRunMap = new Map<
+ string,
+ { status: string; completedAt: string | null }
+ >();
+ for (const run of latestRuns) {
+ latestRunMap.set(run.configId, {
+ status: run.status,
+ completedAt: run.completedAt,
+ });
+ }
+
+ return configs.map((config) => ({
+ ...config,
+ keywordCount: kwCountMap.get(config.id) ?? 0,
+ lastRunStatus: latestRunMap.get(config.id)?.status ?? null,
+ lastRunCompletedAt: latestRunMap.get(config.id)?.completedAt ?? null,
+ }));
+}
+
+async function getKeywordCountForConfig(configId: string) {
+ const rows = await db
+ .select({ value: count() })
+ .from(rankTrackingKeywords)
+ .where(eq(rankTrackingKeywords.configId, configId));
+ return rows[0]?.value ?? 0;
+}
+
+export const RankTrackingRepository = {
+ getConfigsForProject,
+ getConfigById,
+ getConfigByProjectDomainLocation,
+ createConfig,
+ updateConfig,
+ getDueConfigsWithOrganization,
+ createRun,
+ updateRun,
+ getRunById,
+ getLatestRunForConfig,
+ tryCreateRunLock,
+ getRunLock,
+ deleteRunLock,
+ insertSnapshots,
+ getSnapshotsForRun,
+ getRecentCompletedRuns,
+ getClosestCompletedRun,
+ getKeywordsForConfig,
+ addKeywordsToConfig,
+ removeKeywordsFromConfig,
+ getKeywordCountForConfig,
+ getConfigSummaries,
+};
diff --git a/src/server/features/rank-tracking/services/RankTrackingService.ts b/src/server/features/rank-tracking/services/RankTrackingService.ts
new file mode 100644
index 0000000..55509b1
--- /dev/null
+++ b/src/server/features/rank-tracking/services/RankTrackingService.ts
@@ -0,0 +1,311 @@
+import { env } from "cloudflare:workers";
+import type { BillingCustomerContext } from "@/server/billing/subscription";
+import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
+import { AppError } from "@/server/lib/errors";
+import type {
+ RankTrackingConfig,
+ RankCheckTriggerResult,
+} from "@/types/schemas/rank-tracking";
+import {
+ beginRankCheckRun,
+ reconcileActiveRankCheckRun,
+} from "./rankCheckRunGuards";
+import {
+ estimateRankCheckCredits,
+ computeNextCheckAt,
+ devicesCount,
+ MAX_KEYWORDS_PER_CONFIG,
+ MAX_CONFIGS_PER_PROJECT,
+} from "@/shared/rank-tracking";
+
+// ---------------------------------------------------------------------------
+// Config
+// ---------------------------------------------------------------------------
+
+async function createConfig(input: {
+ projectId: string;
+ domain: string;
+ locationCode?: number;
+ languageCode?: string;
+ devices?: RankTrackingConfig["devices"];
+ scheduleInterval?: RankTrackingConfig["scheduleInterval"];
+}) {
+ const normalizedDomain = normalizeDomain(input.domain);
+
+ const locationCode = input.locationCode ?? 2840;
+ const existing =
+ await RankTrackingRepository.getConfigByProjectDomainLocation(
+ input.projectId,
+ normalizedDomain,
+ locationCode,
+ );
+ if (existing) {
+ throw new AppError(
+ "INTERNAL_ERROR",
+ "This domain + country combination is already being tracked",
+ );
+ }
+
+ const allConfigs = await RankTrackingRepository.getConfigsForProject(
+ input.projectId,
+ );
+ if (allConfigs.length >= MAX_CONFIGS_PER_PROJECT) {
+ throw new AppError(
+ "INTERNAL_ERROR",
+ `Maximum ${MAX_CONFIGS_PER_PROJECT} tracked domains per project`,
+ );
+ }
+
+ const configId = crypto.randomUUID();
+ const scheduleInterval = input.scheduleInterval ?? "weekly";
+ const nextCheckAt =
+ scheduleInterval === "daily" || scheduleInterval === "weekly"
+ ? computeNextCheckAt(scheduleInterval)
+ : null;
+
+ await RankTrackingRepository.createConfig({
+ id: configId,
+ projectId: input.projectId,
+ domain: normalizedDomain,
+ locationCode: input.locationCode ?? 2840,
+ languageCode: input.languageCode ?? "en",
+ devices: input.devices ?? "mobile",
+ scheduleInterval,
+ nextCheckAt,
+ });
+
+ return { configId };
+}
+
+async function updateConfig(
+ configId: string,
+ projectId: string,
+ input: {
+ domain?: string;
+ locationCode?: number;
+ languageCode?: string;
+ devices?: RankTrackingConfig["devices"];
+ scheduleInterval?: RankTrackingConfig["scheduleInterval"];
+ isActive?: boolean;
+ },
+) {
+ const updates: typeof input & { nextCheckAt?: string | null } = {};
+
+ if (input.domain !== undefined)
+ updates.domain = normalizeDomain(input.domain);
+ if (input.locationCode !== undefined)
+ updates.locationCode = input.locationCode;
+ if (input.languageCode !== undefined)
+ updates.languageCode = input.languageCode;
+ if (input.devices !== undefined) updates.devices = input.devices;
+ if (input.isActive !== undefined) updates.isActive = input.isActive;
+
+ if (input.scheduleInterval !== undefined) {
+ updates.scheduleInterval = input.scheduleInterval;
+ if (input.scheduleInterval === "manual") {
+ updates.nextCheckAt = null;
+ } else {
+ updates.nextCheckAt = computeNextCheckAt(input.scheduleInterval);
+ }
+ }
+
+ await RankTrackingRepository.updateConfig(configId, projectId, updates);
+}
+
+// ---------------------------------------------------------------------------
+// Keywords
+// ---------------------------------------------------------------------------
+
+async function addKeywords(
+ configId: string,
+ projectId: string,
+ keywords: string[],
+) {
+ await getValidatedConfig(configId, projectId);
+
+ // Filter out keywords that already exist for this config.
+ // We must do this before inserting because onConflictDoNothing silently
+ // skips duplicates but we pre-generate UUIDs — returning those phantom IDs
+ // would cause the auto-check workflow to find no keywords and fail.
+ const existing = await RankTrackingRepository.getKeywordsForConfig(configId);
+
+ if (existing.length >= MAX_KEYWORDS_PER_CONFIG) {
+ throw new AppError(
+ "INTERNAL_ERROR",
+ `Maximum ${MAX_KEYWORDS_PER_CONFIG} keywords per domain. Currently tracking ${existing.length}.`,
+ );
+ }
+
+ const existingKeywords = new Set(existing.map((kw) => kw.keyword));
+ const available = MAX_KEYWORDS_PER_CONFIG - existing.length;
+
+ const seen = new Set();
+ const rows: Array<{ id: string; configId: string; keyword: string }> = [];
+ for (const raw of keywords) {
+ if (rows.length >= available) break;
+ const normalized = raw.trim().toLowerCase();
+ if (
+ normalized &&
+ !seen.has(normalized) &&
+ !existingKeywords.has(normalized)
+ ) {
+ seen.add(normalized);
+ rows.push({ id: crypto.randomUUID(), configId, keyword: normalized });
+ }
+ }
+
+ if (rows.length > 0) {
+ await RankTrackingRepository.addKeywordsToConfig(rows);
+ }
+
+ return { added: rows.length, addedIds: rows.map((r) => r.id) };
+}
+
+async function removeKeywords(
+ configId: string,
+ projectId: string,
+ keywordIds: string[],
+) {
+ await getValidatedConfig(configId, projectId);
+ await RankTrackingRepository.removeKeywordsFromConfig(keywordIds, configId);
+}
+
+// ---------------------------------------------------------------------------
+// Trigger a manual check
+// ---------------------------------------------------------------------------
+
+async function triggerCheck(input: {
+ configId: string;
+ projectId: string;
+ billingCustomer: BillingCustomerContext;
+ keywordIds?: string[];
+}): Promise {
+ const config = await getValidatedConfig(input.configId, input.projectId);
+
+ const keywords = await RankTrackingRepository.getKeywordsForConfig(config.id);
+ if (keywords.length === 0) {
+ throw new AppError(
+ "INTERNAL_ERROR",
+ "No keywords to track. Add keywords to this domain first.",
+ );
+ }
+
+ return beginRankCheckRun({
+ workflow: env.RANK_CHECK_WORKFLOW,
+ config,
+ projectId: input.projectId,
+ billingCustomer: {
+ userId: input.billingCustomer.userId,
+ userEmail: input.billingCustomer.userEmail,
+ organizationId: input.billingCustomer.organizationId,
+ projectId: input.billingCustomer.projectId,
+ },
+ keywordsTotal: keywords.length,
+ keywordIds: input.keywordIds,
+ trigger: "manual",
+ workflowStartErrorMessage: "Failed to start rank check workflow",
+ });
+}
+
+async function getLatestRun(configId: string, projectId: string) {
+ await getValidatedConfig(configId, projectId);
+ const run = await RankTrackingRepository.getLatestRunForConfig(configId);
+ if (!run) return null;
+
+ // If the DB says the run is still active, check the workflow instance.
+ // We only report staleness here — the cron handler cleans up stale locks
+ // when it next tries to acquire one (via cleanupStaleLock). Mutating from
+ // this read path caused a race where the original workflow kept running
+ // while a replacement was started.
+ const reconciliation = await reconcileActiveRankCheckRun(run);
+ if (reconciliation) {
+ return formatRun(run, {
+ maybeStale: true,
+ staleReason: reconciliation.errorMessage,
+ });
+ }
+
+ return formatRun(run);
+}
+
+// ---------------------------------------------------------------------------
+// Cost estimation
+// ---------------------------------------------------------------------------
+
+async function estimateCost(configId: string, projectId: string) {
+ const config = await getValidatedConfig(configId, projectId);
+ const keywordCount =
+ await RankTrackingRepository.getKeywordCountForConfig(configId);
+ const { costUsd, costCredits } = estimateRankCheckCredits(
+ keywordCount,
+ config.devices,
+ );
+ return {
+ costUsd,
+ costCredits,
+ keywordCount,
+ devicesCount: devicesCount(config.devices),
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+async function getValidatedConfig(configId: string, projectId: string) {
+ const config = await RankTrackingRepository.getConfigById({
+ configId,
+ projectId,
+ });
+ if (!config) {
+ throw new AppError("INTERNAL_ERROR", "Rank tracking config not found");
+ }
+ return config;
+}
+
+function normalizeDomain(domain: string): string {
+ let d = domain.trim().toLowerCase();
+ // Strip protocol
+ d = d.replace(/^https?:\/\//, "");
+ // Strip path, query string, and fragment
+ d = d.replace(/[/?#].*$/, "");
+ // Strip trailing slash
+ d = d.replace(/\/+$/, "");
+ // Strip www. prefix
+ d = d.replace(/^www\./, "");
+ if (!d) {
+ throw new AppError("INTERNAL_ERROR", "Invalid domain");
+ }
+ return d;
+}
+
+type RunRow = NonNullable<
+ Awaited>
+>;
+
+function formatRun(
+ run: RunRow,
+ stale?: { maybeStale: boolean; staleReason: string },
+) {
+ return {
+ id: run.id,
+ status: run.status,
+ keywordsTotal: run.keywordsTotal,
+ keywordsChecked: run.keywordsChecked,
+ errorMessage: run.errorMessage,
+ startedAt: run.startedAt,
+ completedAt: run.completedAt,
+ maybeStale: stale?.maybeStale ?? false,
+ staleReason: stale?.staleReason ?? null,
+ };
+}
+
+export const RankTrackingService = {
+ createConfig,
+ updateConfig,
+ addKeywords,
+ removeKeywords,
+ triggerCheck,
+ getLatestRun,
+ estimateCost,
+};
diff --git a/src/server/features/rank-tracking/services/rankCheckRunGuards.ts b/src/server/features/rank-tracking/services/rankCheckRunGuards.ts
new file mode 100644
index 0000000..d72f1cd
--- /dev/null
+++ b/src/server/features/rank-tracking/services/rankCheckRunGuards.ts
@@ -0,0 +1,272 @@
+import { env } from "cloudflare:workers";
+import type { BillingCustomerContext } from "@/server/billing/subscription";
+import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
+import type {
+ RankCheckTriggerResult,
+ RankTrackingConfig,
+} from "@/types/schemas/rank-tracking";
+
+type RunRow = Awaited>;
+type RunLockRow = Awaited>;
+
+// Coordination invariants for rank checks:
+// - `workflow id === run id`, so the workflow instance is the authoritative
+// runtime identity for a stored run.
+// - `rank_check_locks` enforces at most one active runner per config.
+// - Only the lock owner is allowed to spend credits, write snapshots, or
+// finalize the run.
+// - Missing/unknown workflow state is tolerated briefly during startup before
+// we treat the run as stale and repair it.
+
+type RankCheckWorkflowStatus = {
+ status:
+ | "queued"
+ | "running"
+ | "paused"
+ | "errored"
+ | "terminated"
+ | "complete"
+ | "waiting"
+ | "waitingForPause"
+ | "unknown";
+ error?: {
+ message: string;
+ };
+};
+
+type RankCheckConfigForStart = Pick<
+ RankTrackingConfig,
+ "id" | "domain" | "locationCode" | "languageCode" | "devices"
+>;
+
+const ACTIVE_WORKFLOW_STATUSES = new Set([
+ "queued",
+ "running",
+ "waiting",
+ "waitingForPause",
+ "paused",
+]);
+
+const RANK_CHECK_STARTUP_GRACE_MS = 60 * 1000;
+
+async function getRankCheckWorkflowStatus(
+ runId: string,
+): Promise {
+ try {
+ const instance = await env.RANK_CHECK_WORKFLOW.get(runId);
+ return (await instance.status()) as RankCheckWorkflowStatus;
+ } catch {
+ return null;
+ }
+}
+
+function getStaleReason(
+ workflowStatus: RankCheckWorkflowStatus | null,
+ run: RunRow,
+): string {
+ if (run?.status === "completed" || run?.status === "failed") {
+ return `Run already ${run.status}`;
+ }
+ if (!workflowStatus) {
+ return "Workflow instance was not found";
+ }
+ if (
+ workflowStatus.status === "errored" ||
+ workflowStatus.status === "terminated"
+ ) {
+ return workflowStatus.error?.message ?? `Workflow ${workflowStatus.status}`;
+ }
+ if (workflowStatus.status === "complete") {
+ return "Workflow completed without releasing the run lock";
+ }
+ return `Workflow is no longer active (${workflowStatus.status})`;
+}
+
+export async function beginRankCheckRun(input: {
+ workflow: Env["RANK_CHECK_WORKFLOW"];
+ config: RankCheckConfigForStart;
+ projectId: string;
+ billingCustomer: BillingCustomerContext;
+ keywordsTotal: number;
+ keywordIds?: string[];
+ trigger: "manual" | "scheduled";
+ workflowStartErrorMessage: string;
+}): Promise {
+ const runId = crypto.randomUUID();
+ const lockResult = await acquireRankCheckRunLock(input.config.id, runId);
+ if (!lockResult.acquired) {
+ return {
+ ok: false,
+ reason: "already_running",
+ blockingRunId: lockResult.blockingRunId,
+ };
+ }
+
+ try {
+ await RankTrackingRepository.createRun({
+ id: runId,
+ configId: input.config.id,
+ projectId: input.projectId,
+ keywordsTotal: input.keywordsTotal,
+ isSubsetRun: (input.keywordIds?.length ?? 0) > 0,
+ });
+
+ await input.workflow.create({
+ id: runId,
+ params: {
+ runId,
+ configId: input.config.id,
+ billingCustomer: input.billingCustomer,
+ projectId: input.projectId,
+ domain: input.config.domain,
+ locationCode: input.config.locationCode,
+ languageCode: input.config.languageCode,
+ devices: input.config.devices,
+ trigger: input.trigger,
+ keywordIds: input.keywordIds,
+ },
+ });
+ } catch (error) {
+ try {
+ await failRunAndReleaseRankCheckLock(
+ input.config.id,
+ runId,
+ input.workflowStartErrorMessage,
+ );
+ } catch {
+ await releaseRankCheckRunLock(input.config.id, runId);
+ }
+
+ try {
+ const instance = await input.workflow.get(runId);
+ await instance.terminate();
+ } catch {
+ // Workflow may not have been created
+ }
+
+ throw error;
+ }
+
+ return { ok: true, runId };
+}
+
+async function getStaleRankCheckRunReason(input: {
+ run: RunRow;
+ runId: string;
+ ageMs: number;
+}) {
+ const workflowStatus = await getRankCheckWorkflowStatus(input.runId);
+
+ if (workflowStatus && ACTIVE_WORKFLOW_STATUSES.has(workflowStatus.status)) {
+ return null;
+ }
+
+ const startupWindow =
+ input.ageMs < RANK_CHECK_STARTUP_GRACE_MS &&
+ (!input.run ||
+ input.run.status === "pending" ||
+ input.run.status === "running") &&
+ (!workflowStatus || workflowStatus.status === "unknown");
+
+ if (startupWindow) {
+ return null;
+ }
+
+ return getStaleReason(workflowStatus, input.run);
+}
+
+async function failRunIfNeeded(runId: string, reason: string, run: RunRow) {
+ if (!run || run.status === "completed" || run.status === "failed") return;
+ await RankTrackingRepository.updateRun(runId, {
+ status: "failed",
+ errorMessage: reason,
+ completedAt: new Date().toISOString(),
+ });
+}
+
+async function cleanupStaleLock(lock: NonNullable) {
+ const run = await RankTrackingRepository.getRunById(lock.runId);
+ if (run?.status === "completed" || run?.status === "failed") {
+ await RankTrackingRepository.deleteRunLock(lock.configId, lock.runId);
+ return true;
+ }
+
+ const staleReason = await getStaleRankCheckRunReason({
+ runId: lock.runId,
+ run,
+ ageMs: Date.now() - new Date(lock.acquiredAt).getTime(),
+ });
+ if (!staleReason) return false;
+
+ await failRunIfNeeded(lock.runId, staleReason, run);
+ await RankTrackingRepository.deleteRunLock(lock.configId, lock.runId);
+ return true;
+}
+
+export async function reconcileActiveRankCheckRun(run: NonNullable) {
+ if (run.status !== "running" && run.status !== "pending") {
+ return null;
+ }
+
+ const staleReason = await getStaleRankCheckRunReason({
+ runId: run.id,
+ run,
+ ageMs: Date.now() - new Date(run.startedAt).getTime(),
+ });
+ if (!staleReason) return null;
+
+ return {
+ errorMessage: staleReason,
+ completedAt: new Date().toISOString(),
+ };
+}
+
+async function acquireRankCheckRunLock(configId: string, runId: string) {
+ // Optimistic: try to grab the lock immediately
+ if (await RankTrackingRepository.tryCreateRunLock(configId, runId)) {
+ return { acquired: true as const };
+ }
+
+ // Lock exists — check if it's stale and can be cleaned up
+ const existingLock = await RankTrackingRepository.getRunLock(configId);
+ if (!existingLock) {
+ // Lock was released between our insert and select — retry once
+ if (await RankTrackingRepository.tryCreateRunLock(configId, runId)) {
+ return { acquired: true as const };
+ }
+ const blocker = await RankTrackingRepository.getRunLock(configId);
+ return { acquired: false as const, blockingRunId: blocker?.runId ?? null };
+ }
+
+ const cleaned = await cleanupStaleLock(existingLock);
+ if (!cleaned) {
+ return { acquired: false as const, blockingRunId: existingLock.runId };
+ }
+
+ // Stale lock cleaned — retry once
+ if (await RankTrackingRepository.tryCreateRunLock(configId, runId)) {
+ return { acquired: true as const };
+ }
+
+ const blocker = await RankTrackingRepository.getRunLock(configId);
+ return { acquired: false as const, blockingRunId: blocker?.runId ?? null };
+}
+
+export async function runOwnsRankCheckLock(configId: string, runId: string) {
+ const lock = await RankTrackingRepository.getRunLock(configId);
+ return lock?.runId === runId;
+}
+
+export async function releaseRankCheckRunLock(configId: string, runId: string) {
+ await RankTrackingRepository.deleteRunLock(configId, runId);
+}
+
+export async function failRunAndReleaseRankCheckLock(
+ configId: string,
+ runId: string,
+ errorMessage: string,
+) {
+ const run = await RankTrackingRepository.getRunById(runId);
+ await failRunIfNeeded(runId, errorMessage, run);
+ await RankTrackingRepository.deleteRunLock(configId, runId);
+}
diff --git a/src/server/features/rank-tracking/services/rankTrackingResults.ts b/src/server/features/rank-tracking/services/rankTrackingResults.ts
new file mode 100644
index 0000000..d65e6f5
--- /dev/null
+++ b/src/server/features/rank-tracking/services/rankTrackingResults.ts
@@ -0,0 +1,155 @@
+import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
+import { AppError } from "@/server/lib/errors";
+import type { ComparePeriod } from "@/types/schemas/rank-tracking";
+import type {
+ RankTrackingDeviceResult,
+ RankTrackingRow,
+} from "@/types/schemas/rank-tracking";
+
+type SnapshotRow = Awaited<
+ ReturnType
+>[0];
+
+const PERIOD_DAYS: Record, number> = {
+ "7d": 7,
+ "30d": 30,
+ "90d": 90,
+};
+
+export async function getLatestResults(
+ configId: string,
+ projectId: string,
+ comparePeriod: ComparePeriod = "previous",
+): Promise<{
+ rows: RankTrackingRow[];
+ run: { id: string; startedAt: string } | null;
+}> {
+ const config = await RankTrackingRepository.getConfigById({
+ configId,
+ projectId,
+ });
+ if (!config) {
+ throw new AppError("INTERNAL_ERROR", "Rank tracking config not found");
+ }
+
+ const recentRuns = await RankTrackingRepository.getRecentCompletedRuns(
+ configId,
+ 2,
+ );
+ const currentRun = recentRuns[0];
+ if (!currentRun) {
+ return { rows: [], run: null };
+ }
+
+ const currentSnapshots = await RankTrackingRepository.getSnapshotsForRun(
+ currentRun.id,
+ );
+
+ // Load comparison run's snapshots for delta computation
+ const previousPositions = new Map();
+ let comparisonRun: typeof currentRun | null = null;
+
+ if (comparePeriod === "previous") {
+ comparisonRun = recentRuns[1] ?? null;
+ } else {
+ const days = PERIOD_DAYS[comparePeriod];
+ const targetDate = new Date(
+ Date.now() - days * 24 * 60 * 60 * 1000,
+ ).toISOString();
+ const closest = await RankTrackingRepository.getClosestCompletedRun(
+ configId,
+ targetDate,
+ );
+ // Don't compare a run against itself
+ if (closest && closest.id !== currentRun.id) {
+ comparisonRun = closest;
+ }
+ }
+
+ if (comparisonRun) {
+ const prevSnapshots = await RankTrackingRepository.getSnapshotsForRun(
+ comparisonRun.id,
+ );
+ for (const snap of prevSnapshots) {
+ previousPositions.set(
+ `${snap.trackingKeywordId}:${snap.device}`,
+ snap.position,
+ );
+ }
+ }
+
+ const activeKeywords =
+ await RankTrackingRepository.getKeywordsForConfig(configId);
+ const rows = new Map(
+ activeKeywords.map((keyword) => [
+ keyword.id,
+ {
+ trackingKeywordId: keyword.id,
+ keyword: keyword.keyword,
+ desktop: createEmptyDeviceResult(
+ previousPositions.get(`${keyword.id}:desktop`) ?? null,
+ ),
+ mobile: createEmptyDeviceResult(
+ previousPositions.get(`${keyword.id}:mobile`) ?? null,
+ ),
+ },
+ ]),
+ );
+
+ for (const snapshot of currentSnapshots) {
+ const row = rows.get(snapshot.trackingKeywordId);
+ if (!row) continue;
+ row[snapshot.device] = toDeviceResult(
+ snapshot,
+ previousPositions.get(
+ `${snapshot.trackingKeywordId}:${snapshot.device}`,
+ ) ?? null,
+ );
+ }
+
+ return {
+ rows: activeKeywords
+ .map((keyword) => rows.get(keyword.id))
+ .filter((row): row is RankTrackingRow => row != null),
+ run: {
+ id: currentRun.id,
+ startedAt: currentRun.startedAt,
+ },
+ };
+}
+
+function parseSerpFeatures(raw: string | null): string[] {
+ if (!raw) return [];
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ if (Array.isArray(parsed)) {
+ return parsed.filter((item): item is string => typeof item === "string");
+ }
+ } catch {
+ // ignore
+ }
+ return [];
+}
+
+function createEmptyDeviceResult(
+ previousPosition: number | null,
+): RankTrackingDeviceResult {
+ return {
+ position: null,
+ previousPosition,
+ rankingUrl: null,
+ serpFeatures: [],
+ };
+}
+
+function toDeviceResult(
+ snapshot: SnapshotRow,
+ previousPosition: number | null,
+): RankTrackingDeviceResult {
+ return {
+ position: snapshot.position,
+ previousPosition,
+ rankingUrl: snapshot.url,
+ serpFeatures: parseSerpFeatures(snapshot.serpFeatures),
+ };
+}
diff --git a/src/server/lib/dataforseo.ts b/src/server/lib/dataforseo.ts
index b7e9dca..651dc11 100644
--- a/src/server/lib/dataforseo.ts
+++ b/src/server/lib/dataforseo.ts
@@ -1,3 +1,4 @@
+/* eslint-disable max-lines */
import {
DataforseoLabsApi,
DataforseoLabsGoogleRelatedKeywordsLiveRequestInfo,
@@ -7,6 +8,7 @@ import {
DataforseoLabsGoogleRankedKeywordsLiveRequestInfo,
} from "dataforseo-client";
import { env } from "cloudflare:workers";
+import { z } from "zod";
import type { DataforseoApiResponse } from "@/server/lib/dataforseoCost";
import { AppError } from "@/server/lib/errors";
import {
@@ -380,3 +382,99 @@ export async function fetchLiveSerpItemsRaw(
billing: buildTaskBilling(task),
};
}
+
+// ---------------------------------------------------------------------------
+// SERP Rank Check API wrapper (Google Organic Live with target matching)
+// ---------------------------------------------------------------------------
+
+export interface RankCheckResult {
+ keywordId: string;
+ keyword: string;
+ position: number | null;
+ url: string | null;
+ serpFeatures: string[];
+}
+
+export async function fetchRankCheckSerpRaw(input: {
+ keyword: string;
+ keywordId: string;
+ locationCode: number;
+ languageCode: string;
+ device: "desktop" | "mobile";
+ targetDomain: string;
+}): Promise> {
+ const responseRaw = await postDataforseo(
+ "/v3/serp/google/organic/live/advanced",
+ [
+ {
+ keyword: input.keyword,
+ location_code: input.locationCode,
+ language_code: input.languageCode,
+ device: input.device,
+ os: input.device === "desktop" ? "windows" : "android",
+ depth: 20,
+ target: input.targetDomain,
+ },
+ ],
+ );
+
+ const response = dataforseoResponseSchema.parse(responseRaw);
+
+ if (response.status_code !== 20000) {
+ throw new AppError(
+ "INTERNAL_ERROR",
+ response.status_message || "DataForSEO request failed",
+ );
+ }
+
+ const task = response.tasks?.[0];
+ if (!task) {
+ throw new AppError("INTERNAL_ERROR", "DataForSEO response missing task");
+ }
+
+ // "No Search Results" (40501) is valid for obscure/new keywords —
+ // treat as empty result set rather than failing the entire run.
+ const isNoResults =
+ task.status_code === 40501 ||
+ task.status_message?.toLowerCase().includes("no search results");
+ if (task.status_code !== 20000 && !isNoResults) {
+ throw new AppError(
+ "INTERNAL_ERROR",
+ task.status_message || "DataForSEO task failed",
+ );
+ }
+
+ const parsedTask = successfulDataforseoTaskSchema.safeParse(task);
+ if (!parsedTask.success) {
+ throw new AppError(
+ "INTERNAL_ERROR",
+ `DataForSEO rank check task missing billing metadata`,
+ );
+ }
+
+ const items = z
+ .array(serpSnapshotItemSchema)
+ .parse(parsedTask.data.result?.[0]?.items ?? []);
+
+ const target = input.targetDomain.toLowerCase();
+ const organicMatch = items.find((item) => {
+ if (item.type !== "organic" || item.domain == null) return false;
+ const d = item.domain.toLowerCase();
+ return d === target || d.endsWith(`.${target}`);
+ });
+
+ return {
+ data: {
+ keywordId: input.keywordId,
+ keyword: input.keyword,
+ position: organicMatch
+ ? (organicMatch.rank_absolute ?? organicMatch.rank_group ?? null)
+ : null,
+ url: organicMatch?.url ?? null,
+ serpFeatures: [
+ ...new Set(items.map((item) => item.type).filter(Boolean)),
+ ],
+ },
+ billing: buildTaskBilling(parsedTask.data),
+ };
+}
diff --git a/src/server/lib/dataforseoClient.ts b/src/server/lib/dataforseoClient.ts
index cdfceec..6872035 100644
--- a/src/server/lib/dataforseoClient.ts
+++ b/src/server/lib/dataforseoClient.ts
@@ -15,6 +15,7 @@ import {
fetchDomainRankOverviewRaw,
fetchRankedKeywordsRaw,
fetchLiveSerpItemsRaw,
+ fetchRankCheckSerpRaw,
type LabsKeywordDataItem,
type SerpLiveItem,
} from "@/server/lib/dataforseo";
@@ -43,7 +44,8 @@ type CreditFeature =
| "keyword_research"
| "domain_overview"
| "backlinks"
- | "site_audit";
+ | "site_audit"
+ | "rank_tracking";
/**
* Maps a DataForSEO API response path (e.g. ["v3", "dataforseo_labs", "google", "related_keywords", "live"])
@@ -198,6 +200,20 @@ export function createDataforseoClient(customer: BillingCustomerContext) {
),
);
},
+ rankCheck(input: {
+ keyword: string;
+ keywordId: string;
+ locationCode: number;
+ languageCode: string;
+ device: "desktop" | "mobile";
+ targetDomain: string;
+ }) {
+ return meterDataforseoCall(
+ customer,
+ () => fetchRankCheckSerpRaw(input),
+ "rank_tracking",
+ );
+ },
},
lighthouse: {
live(input: { url: string; strategy: LighthouseStrategy }) {
@@ -212,6 +228,7 @@ export function createDataforseoClient(customer: BillingCustomerContext) {
async function meterDataforseoCall(
customer: BillingCustomerContext,
execute: () => Promise>,
+ creditFeature?: CreditFeature,
): Promise {
const isHostedMode = await isHostedServerAuthMode();
@@ -233,6 +250,7 @@ async function meterDataforseoCall(
customerId: billingCustomer.id,
billing: result.billing,
monthlyRemaining,
+ creditFeature,
});
return result.data;
@@ -265,6 +283,7 @@ async function trackDataforseoCost(args: {
customerId: string;
billing: DataforseoApiCallCost;
monthlyRemaining: number;
+ creditFeature?: CreditFeature;
}) {
const totalCostUsd = roundUsdForBilling(
args.billing.costUsd * SEO_DATA_COST_MARKUP,
@@ -316,7 +335,9 @@ async function trackDataforseoCost(args: {
organizationId: args.customer.organizationId,
properties: {
project_id: args.customer.projectId,
- credit_feature: mapDataforseoPathToCreditFeature(args.billing.path),
+ credit_feature:
+ args.creditFeature ??
+ mapDataforseoPathToCreditFeature(args.billing.path),
monthly_credits: monthlyDeduct,
topup_credits: topupDeduct,
total_credits: totalCostCredits,
diff --git a/src/server/workflows/RankCheckWorkflow.ts b/src/server/workflows/RankCheckWorkflow.ts
new file mode 100644
index 0000000..a91b7dd
--- /dev/null
+++ b/src/server/workflows/RankCheckWorkflow.ts
@@ -0,0 +1,307 @@
+import {
+ WorkflowEntrypoint,
+ type WorkflowEvent,
+ type WorkflowStep,
+} from "cloudflare:workers";
+import { NonRetryableError } from "cloudflare:workflows";
+import type { BillingCustomerContext } from "@/server/billing/subscription";
+import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
+import {
+ failRunAndReleaseRankCheckLock,
+ releaseRankCheckRunLock,
+ runOwnsRankCheckLock,
+} from "@/server/features/rank-tracking/services/rankCheckRunGuards";
+import { runLiveCheck } from "@/server/workflows/rankCheckPaths";
+import { createDataforseoClient } from "@/server/lib/dataforseoClient";
+import { captureServerEvent } from "@/server/lib/posthog";
+import { AppError } from "@/server/lib/errors";
+import { autumn } from "@/server/billing/autumn";
+import {
+ AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
+ AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
+} from "@/shared/billing";
+import { estimateRankCheckCredits } from "@/shared/rank-tracking";
+import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
+
+const SINGLE_ATTEMPT_STEP_CONFIG = {
+ retries: { limit: 0, delay: "1 second" as const },
+ timeout: "2 minutes" as const,
+};
+
+interface RankCheckParams {
+ runId: string;
+ configId: string;
+ billingCustomer: BillingCustomerContext;
+ projectId: string;
+ domain: string;
+ locationCode: number;
+ languageCode: string;
+ devices: "both" | "desktop" | "mobile";
+ trigger: "manual" | "scheduled";
+ keywordIds?: string[];
+}
+
+async function prepareRankCheckKeywords(input: {
+ runId: string;
+ configId: string;
+ billingCustomer: BillingCustomerContext;
+ devices: RankCheckParams["devices"];
+ keywordIds?: string[];
+}) {
+ const ownsLock = await runOwnsRankCheckLock(input.configId, input.runId);
+ if (!ownsLock) {
+ throw new NonRetryableError(
+ `Rank check lock is not held by run ${input.runId}`,
+ );
+ }
+
+ await RankTrackingRepository.updateRun(input.runId, {
+ status: "running",
+ });
+
+ let trackingKeywords = await RankTrackingRepository.getKeywordsForConfig(
+ input.configId,
+ );
+
+ if (input.keywordIds && input.keywordIds.length > 0) {
+ const idSet = new Set(input.keywordIds);
+ trackingKeywords = trackingKeywords.filter((kw) => idSet.has(kw.id));
+ }
+
+ if (trackingKeywords.length === 0) {
+ throw new AppError("INTERNAL_ERROR", "No keywords to track");
+ }
+
+ // Verify the user has enough credits for the full check before starting
+ if (await isHostedServerAuthMode()) {
+ const { costCredits } = estimateRankCheckCredits(
+ trackingKeywords.length,
+ input.devices,
+ );
+ const [monthlyCheck, topupCheck] = await Promise.all([
+ autumn.check({
+ customerId: input.billingCustomer.organizationId,
+ featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
+ }),
+ autumn.check({
+ customerId: input.billingCustomer.organizationId,
+ featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
+ }),
+ ]);
+ const available =
+ (monthlyCheck.balance?.remaining ?? 0) +
+ (topupCheck.balance?.remaining ?? 0);
+ if (available < costCredits) {
+ throw new AppError(
+ "INSUFFICIENT_CREDITS",
+ "Insufficient credits for rank check",
+ );
+ }
+ }
+
+ await RankTrackingRepository.updateRun(input.runId, {
+ keywordsTotal: trackingKeywords.length,
+ });
+
+ return {
+ keywords: trackingKeywords.map((kw) => ({
+ id: kw.id,
+ keyword: kw.keyword,
+ })),
+ };
+}
+
+async function finalizeRankCheckRun(input: {
+ runId: string;
+ configId: string;
+ projectId: string;
+ billingCustomer: BillingCustomerContext;
+ trigger: RankCheckParams["trigger"];
+ batchError: string | null;
+}) {
+ // Re-check lock ownership before finalizing. If the lock was stolen
+ // (stale cleanup raced with a slow workflow), bail out to avoid
+ // overwriting the replacement run's state.
+ const ownsLock = await runOwnsRankCheckLock(input.configId, input.runId);
+ if (!ownsLock) {
+ console.warn(
+ `[rank-check] ${input.runId} lost lock ownership, skipping finalization`,
+ );
+ return;
+ }
+
+ const nowIso = new Date().toISOString();
+
+ // Snapshots were written incrementally by each batch step.
+ // Count from DB to get the authoritative keyword count.
+ const snapshots = await RankTrackingRepository.getSnapshotsForRun(
+ input.runId,
+ );
+ const keywordsChecked = new Set(snapshots.map((s) => s.trackingKeywordId))
+ .size;
+
+ // Derive incompleteCount from the run's keywordsTotal (set in prepare step)
+ const run = await RankTrackingRepository.getRunById(input.runId);
+ const keywordsTotal = run?.keywordsTotal ?? keywordsChecked;
+ const incompleteCount = keywordsTotal - keywordsChecked;
+
+ let errorMessage: string | undefined;
+ if (input.batchError) {
+ errorMessage = `Completed ${keywordsChecked} of ${keywordsTotal} keyword(s). Error: ${input.batchError}`;
+ } else if (incompleteCount > 0) {
+ errorMessage = `${incompleteCount} keyword(s) could not be checked`;
+ }
+
+ await RankTrackingRepository.updateRun(input.runId, {
+ status: "completed",
+ keywordsChecked,
+ completedAt: nowIso,
+ ...(errorMessage ? { errorMessage } : {}),
+ });
+
+ // Clear any previous skip reason on success.
+ // Note: nextCheckAt is NOT set here — the cron handler advances it eagerly
+ // before starting the workflow to prevent retry storms.
+ await RankTrackingRepository.updateConfig(input.configId, input.projectId, {
+ lastCheckedAt: nowIso,
+ lastSkipReason: null,
+ });
+
+ await releaseRankCheckRunLock(input.configId, input.runId);
+
+ await captureServerEvent({
+ distinctId: input.billingCustomer.userId,
+ event: "rank_tracking:check_complete",
+ organizationId: input.billingCustomer.organizationId,
+ properties: {
+ project_id: input.projectId,
+ status: "completed",
+ trigger: input.trigger,
+ keywords_checked: keywordsChecked,
+ },
+ });
+}
+
+async function markRankCheckRunFailed(input: {
+ runId: string;
+ configId: string;
+ projectId: string;
+ billingCustomer: BillingCustomerContext;
+ error: unknown;
+}) {
+ const errorMessage =
+ input.error instanceof Error ? input.error.message : "Unknown error";
+ await failRunAndReleaseRankCheckLock(
+ input.configId,
+ input.runId,
+ errorMessage,
+ );
+
+ // Flag the config so the UI can show why the scheduled check was skipped
+ const isInsufficientCredits =
+ input.error instanceof AppError &&
+ input.error.code === "INSUFFICIENT_CREDITS";
+ if (isInsufficientCredits) {
+ await RankTrackingRepository.updateConfig(input.configId, input.projectId, {
+ lastSkipReason: "insufficient_credits",
+ });
+ }
+
+ await captureServerEvent({
+ distinctId: input.billingCustomer.userId,
+ event: "rank_tracking:check_complete",
+ organizationId: input.billingCustomer.organizationId,
+ properties: {
+ project_id: input.projectId,
+ status: "failed",
+ error: errorMessage,
+ },
+ });
+}
+
+export class RankCheckWorkflow extends WorkflowEntrypoint<
+ Env,
+ RankCheckParams
+> {
+ async run(event: WorkflowEvent, step: WorkflowStep) {
+ const {
+ runId,
+ configId,
+ billingCustomer,
+ projectId,
+ domain,
+ locationCode,
+ languageCode,
+ devices,
+ trigger,
+ keywordIds,
+ } = event.payload;
+
+ const client = createDataforseoClient(billingCustomer);
+
+ try {
+ console.log(
+ `[rank-check] ${runId} starting (trigger=${trigger}, devices=${devices})`,
+ );
+
+ const prepareResult = await step.do(
+ "prepare",
+ { retries: { limit: 0, delay: "1 second" } },
+ async () =>
+ prepareRankCheckKeywords({
+ runId,
+ configId,
+ billingCustomer,
+ devices,
+ keywordIds,
+ }),
+ );
+
+ const keywords = prepareResult.keywords;
+
+ console.log(`[rank-check] ${runId} loaded ${keywords.length} keywords`);
+
+ let batchError: string | null = null;
+
+ try {
+ await runLiveCheck(step, {
+ client,
+ keywords,
+ devices,
+ domain,
+ locationCode,
+ languageCode,
+ runId,
+ });
+ } catch (error) {
+ // Batch failure — snapshots for completed batches are already
+ // persisted incrementally. Continue to finalization.
+ batchError = error instanceof Error ? error.message : String(error);
+ console.warn(`[rank-check] ${runId} partial failure: ${batchError}`);
+ }
+
+ await step.do("finalize", SINGLE_ATTEMPT_STEP_CONFIG, async () =>
+ finalizeRankCheckRun({
+ runId,
+ configId,
+ projectId,
+ billingCustomer,
+ trigger,
+ batchError,
+ }),
+ );
+ } catch (error) {
+ console.error(`Rank check ${runId} failed:`, error);
+ await step.do("mark-failed", SINGLE_ATTEMPT_STEP_CONFIG, async () =>
+ markRankCheckRunFailed({
+ runId,
+ configId,
+ projectId,
+ billingCustomer,
+ error,
+ }),
+ );
+ throw error;
+ }
+ }
+}
diff --git a/src/server/workflows/rankCheckPaths.ts b/src/server/workflows/rankCheckPaths.ts
new file mode 100644
index 0000000..8737c8b
--- /dev/null
+++ b/src/server/workflows/rankCheckPaths.ts
@@ -0,0 +1,113 @@
+import type { WorkflowStep } from "cloudflare:workers";
+import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
+import type { createDataforseoClient } from "@/server/lib/dataforseoClient";
+import type { RankCheckResult } from "@/server/lib/dataforseo";
+import type { RankTrackingConfig } from "@/types/schemas/rank-tracking";
+import { KEYWORDS_PER_BATCH } from "@/shared/rank-tracking";
+
+const SINGLE_ATTEMPT_STEP_CONFIG = {
+ retries: { limit: 0, delay: "1 second" as const },
+ timeout: "2 minutes" as const,
+};
+
+type KeywordEntry = { id: string; keyword: string };
+type RankCheckResultWithDevice = RankCheckResult & {
+ device: "desktop" | "mobile";
+};
+
+function mapResultsToSnapshotRows(
+ runId: string,
+ results: RankCheckResultWithDevice[],
+) {
+ return results.map((r) => ({
+ runId,
+ trackingKeywordId: r.keywordId,
+ keyword: r.keyword,
+ device: r.device,
+ position: r.position,
+ url: r.url,
+ serpFeatures:
+ r.serpFeatures.length > 0 ? JSON.stringify(r.serpFeatures) : null,
+ }));
+}
+
+interface CheckContext {
+ client: ReturnType;
+ keywords: KeywordEntry[];
+ devices: RankTrackingConfig["devices"];
+ domain: string;
+ locationCode: number;
+ languageCode: string;
+ runId: string;
+}
+
+/**
+ * Check keywords via Live API, parallel devices per keyword, real-time progress.
+ * Snapshots are written incrementally after each batch so partial results
+ * survive batch failures. ~6s per keyword batch.
+ * Billing is handled per-call by the metered client.
+ */
+export async function runLiveCheck(
+ step: WorkflowStep,
+ ctx: CheckContext,
+): Promise<{ totalFailed: number }> {
+ const deviceList: Array<"desktop" | "mobile"> =
+ ctx.devices === "both" ? ["desktop", "mobile"] : [ctx.devices];
+ let checked = 0;
+ let totalFailed = 0;
+
+ for (let i = 0; i < ctx.keywords.length; i += KEYWORDS_PER_BATCH) {
+ const batch = ctx.keywords.slice(i, i + KEYWORDS_PER_BATCH);
+ const batchIndex = Math.floor(i / KEYWORDS_PER_BATCH);
+
+ const batchResults = await step.do(
+ `live-batch-${batchIndex}`,
+ SINGLE_ATTEMPT_STEP_CONFIG,
+ async () => {
+ const promises = batch.flatMap((kw) =>
+ deviceList.map((device) =>
+ ctx.client.serp
+ .rankCheck({
+ keyword: kw.keyword,
+ keywordId: kw.id,
+ locationCode: ctx.locationCode,
+ languageCode: ctx.languageCode,
+ device,
+ targetDomain: ctx.domain,
+ })
+ .then((r) => ({ ...r, device })),
+ ),
+ );
+ const settled = await Promise.allSettled(promises);
+ const results: RankCheckResultWithDevice[] = [];
+ let batchFailed = 0;
+ for (const outcome of settled) {
+ if (outcome.status === "fulfilled") {
+ results.push(outcome.value);
+ } else {
+ batchFailed++;
+ console.error("Rank check call failed:", outcome.reason);
+ }
+ }
+ checked += batch.length;
+ await RankTrackingRepository.updateRun(ctx.runId, {
+ keywordsChecked: checked,
+ });
+ if (results.length > 0) {
+ await RankTrackingRepository.insertSnapshots(
+ mapResultsToSnapshotRows(ctx.runId, results),
+ );
+ }
+ return { batchFailed };
+ },
+ );
+
+ totalFailed += batchResults.batchFailed;
+ }
+
+ if (totalFailed > 0) {
+ console.warn(`Rank check completed with ${totalFailed} failed API call(s)`);
+ }
+
+ return { totalFailed };
+}
diff --git a/src/serverFunctions/audit.ts b/src/serverFunctions/audit.ts
index 47498cd..da133c2 100644
--- a/src/serverFunctions/audit.ts
+++ b/src/serverFunctions/audit.ts
@@ -18,11 +18,7 @@ export const startAudit = createServerFn({ method: "POST" })
.handler(async ({ data, context }) => {
const result = await AuditService.startAudit({
actorUserId: context.userId,
- billingCustomer: {
- organizationId: context.organizationId,
- userEmail: context.userEmail,
- userId: context.userId,
- },
+ billingCustomer: context,
projectId: context.projectId,
startUrl: data.startUrl,
maxPages: data.maxPages,
diff --git a/src/serverFunctions/rank-tracking.ts b/src/serverFunctions/rank-tracking.ts
new file mode 100644
index 0000000..7aa03d1
--- /dev/null
+++ b/src/serverFunctions/rank-tracking.ts
@@ -0,0 +1,154 @@
+import { createServerFn } from "@tanstack/react-start";
+import { waitUntil } from "cloudflare:workers";
+import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
+import { RankTrackingService } from "@/server/features/rank-tracking/services/RankTrackingService";
+import { getLatestResults } from "@/server/features/rank-tracking/services/rankTrackingResults";
+import { captureServerEvent } from "@/server/lib/posthog";
+import { requireProjectContext } from "@/serverFunctions/middleware";
+import {
+ getConfigsSchema,
+ createConfigSchema,
+ updateConfigSchema,
+ triggerCheckSchema,
+ getLatestResultsSchema,
+ getLatestRunSchema,
+ estimateCostSchema,
+ addKeywordsSchema,
+ removeKeywordsSchema,
+} from "@/types/schemas/rank-tracking";
+
+export const getRankTrackingConfigs = createServerFn({ method: "POST" })
+ .middleware(requireProjectContext)
+ .inputValidator((data: unknown) => getConfigsSchema.parse(data))
+ .handler(async ({ context }) => {
+ return RankTrackingRepository.getConfigsForProject(context.projectId);
+ });
+
+export const getRankTrackingConfigSummaries = createServerFn({ method: "POST" })
+ .middleware(requireProjectContext)
+ .inputValidator((data: unknown) => getConfigsSchema.parse(data))
+ .handler(async ({ context }) => {
+ return RankTrackingRepository.getConfigSummaries(context.projectId);
+ });
+
+export const createRankTrackingConfig = createServerFn({ method: "POST" })
+ .middleware(requireProjectContext)
+ .inputValidator((data: unknown) => createConfigSchema.parse(data))
+ .handler(async ({ data, context }) => {
+ const result = await RankTrackingService.createConfig({
+ projectId: context.projectId,
+ domain: data.domain,
+ locationCode: data.locationCode,
+ languageCode: data.languageCode,
+ devices: data.devices,
+ scheduleInterval: data.scheduleInterval,
+ });
+
+ waitUntil(
+ captureServerEvent({
+ distinctId: context.userId,
+ event: "rank_tracking:config_create",
+ organizationId: context.organizationId,
+ properties: {
+ project_id: context.projectId,
+ domain: data.domain,
+ devices: data.devices ?? "both",
+ schedule: data.scheduleInterval ?? "weekly",
+ },
+ }),
+ );
+
+ return result;
+ });
+
+export const updateRankTrackingConfig = createServerFn({ method: "POST" })
+ .middleware(requireProjectContext)
+ .inputValidator((data: unknown) => updateConfigSchema.parse(data))
+ .handler(async ({ data, context }) => {
+ await RankTrackingService.updateConfig(data.configId, context.projectId, {
+ domain: data.domain,
+ locationCode: data.locationCode,
+ languageCode: data.languageCode,
+ devices: data.devices,
+ scheduleInterval: data.scheduleInterval,
+ isActive: data.isActive,
+ });
+ return { success: true };
+ });
+
+export const triggerRankCheck = createServerFn({ method: "POST" })
+ .middleware(requireProjectContext)
+ .inputValidator((data: unknown) => triggerCheckSchema.parse(data))
+ .handler(async ({ data, context }) => {
+ const result = await RankTrackingService.triggerCheck({
+ configId: data.configId,
+ projectId: context.projectId,
+ billingCustomer: context,
+ keywordIds: data.keywordIds,
+ });
+
+ if (result.ok) {
+ waitUntil(
+ captureServerEvent({
+ distinctId: context.userId,
+ event: "rank_tracking:check_trigger",
+ organizationId: context.organizationId,
+ properties: {
+ project_id: context.projectId,
+ config_id: data.configId,
+ run_id: result.runId,
+ },
+ }),
+ );
+ }
+
+ return result;
+ });
+
+export const getLatestRankResults = createServerFn({ method: "POST" })
+ .middleware(requireProjectContext)
+ .inputValidator((data: unknown) => getLatestResultsSchema.parse(data))
+ .handler(async ({ data, context }) => {
+ return getLatestResults(
+ data.configId,
+ context.projectId,
+ data.comparePeriod,
+ );
+ });
+
+export const getLatestRankRun = createServerFn({ method: "POST" })
+ .middleware(requireProjectContext)
+ .inputValidator((data: unknown) => getLatestRunSchema.parse(data))
+ .handler(async ({ data, context }) => {
+ return RankTrackingService.getLatestRun(data.configId, context.projectId);
+ });
+
+export const estimateRankCheckCost = createServerFn({ method: "POST" })
+ .middleware(requireProjectContext)
+ .inputValidator((data: unknown) => estimateCostSchema.parse(data))
+ .handler(async ({ data, context }) => {
+ return RankTrackingService.estimateCost(data.configId, context.projectId);
+ });
+
+export const addTrackingKeywords = createServerFn({ method: "POST" })
+ .middleware(requireProjectContext)
+ .inputValidator((data: unknown) => addKeywordsSchema.parse(data))
+ .handler(async ({ data, context }) => {
+ return RankTrackingService.addKeywords(
+ data.configId,
+ context.projectId,
+ data.keywords,
+ );
+ });
+
+export const removeTrackingKeywords = createServerFn({ method: "POST" })
+ .middleware(requireProjectContext)
+ .inputValidator((data: unknown) => removeKeywordsSchema.parse(data))
+ .handler(async ({ data, context }) => {
+ await RankTrackingService.removeKeywords(
+ data.configId,
+ context.projectId,
+ data.keywordIds,
+ );
+ return { removed: data.keywordIds.length };
+ });
diff --git a/src/shared/rank-tracking.ts b/src/shared/rank-tracking.ts
new file mode 100644
index 0000000..f994111
--- /dev/null
+++ b/src/shared/rank-tracking.ts
@@ -0,0 +1,97 @@
+import {
+ AUTUMN_SEO_DATA_CREDITS_PER_USD,
+ SEO_DATA_COST_MARKUP,
+ roundUsdForBilling,
+} from "./billing";
+import type { RankTrackingConfig } from "@/types/schemas/rank-tracking";
+
+// ---------------------------------------------------------------------------
+// Cost constants
+// ---------------------------------------------------------------------------
+
+/** Per-SERP cost from DataForSEO Live API */
+const COST_PER_SERP_USD = 0.002;
+
+/** How many keywords are checked per batch */
+export const KEYWORDS_PER_BATCH = 10;
+
+/** Approximate seconds per batch */
+export const SECONDS_PER_BATCH = 6;
+
+/** Maximum keywords allowed per rank tracking config */
+export const MAX_KEYWORDS_PER_CONFIG = 1000;
+
+/** Maximum configs (domain+location combos) per project */
+export const MAX_CONFIGS_PER_PROJECT = 20;
+
+// ---------------------------------------------------------------------------
+// Cost estimation
+// ---------------------------------------------------------------------------
+
+export function estimateRankCheckCredits(
+ keywordCount: number,
+ devices: RankTrackingConfig["devices"],
+) {
+ const totalChecks = keywordCount * devicesCount(devices);
+ const costUsd = roundUsdForBilling(
+ totalChecks * COST_PER_SERP_USD * SEO_DATA_COST_MARKUP,
+ );
+ const costCredits = Math.ceil(costUsd * AUTUMN_SEO_DATA_CREDITS_PER_USD);
+ return { costUsd, costCredits };
+}
+
+// ---------------------------------------------------------------------------
+// Schedule
+// ---------------------------------------------------------------------------
+
+/**
+ * Compute the next check time for a scheduled config.
+ *
+ * If `previousNextCheckAt` is provided, advances from that anchor by the
+ * interval until the result is in the future. This prevents schedule drift
+ * when runs are delayed (e.g., a weekly config due Monday that fires on
+ * Wednesday will still schedule the next check for the following Monday).
+ *
+ * Otherwise a random hour (04–09 UTC) and minute are chosen.
+ */
+export function computeNextCheckAt(
+ interval: "daily" | "weekly",
+ previousNextCheckAt?: string | null,
+): string {
+ const daysAhead = interval === "daily" ? 1 : 7;
+
+ if (previousNextCheckAt) {
+ const anchor = new Date(previousNextCheckAt).getTime();
+ const intervalMs = daysAhead * 86_400_000;
+ const steps = Math.floor(Math.max(0, Date.now() - anchor) / intervalMs) + 1;
+ return new Date(anchor + steps * intervalMs).toISOString();
+ }
+
+ const nextDate = new Date();
+ nextDate.setUTCDate(nextDate.getUTCDate() + daysAhead);
+ const hour = 4 + Math.floor(Math.random() * 6);
+ const minute = Math.floor(Math.random() * 60);
+ nextDate.setUTCHours(hour, minute, 0, 0);
+ return nextDate.toISOString();
+}
+
+// ---------------------------------------------------------------------------
+// Display labels
+// ---------------------------------------------------------------------------
+
+export function devicesLabel(devices: RankTrackingConfig["devices"]): string {
+ if (devices === "both") return "Desktop + Mobile";
+ return devices === "desktop" ? "Desktop" : "Mobile";
+}
+
+export function scheduleLabel(
+ interval: RankTrackingConfig["scheduleInterval"],
+): string {
+ if (interval === "daily") return "Daily";
+ if (interval === "weekly") return "Weekly";
+ return "Manual";
+}
+
+export function devicesCount(devices: RankTrackingConfig["devices"]): number {
+ return devices === "both" ? 2 : 1;
+}
diff --git a/src/types/schemas/rank-tracking.ts b/src/types/schemas/rank-tracking.ts
new file mode 100644
index 0000000..7b3b667
--- /dev/null
+++ b/src/types/schemas/rank-tracking.ts
@@ -0,0 +1,120 @@
+import type { InferSelectModel } from "drizzle-orm";
+import { z } from "zod";
+import { rankTrackingConfigs } from "@/db/app.schema";
+
+// ---------------------------------------------------------------------------
+// DB-derived types
+// ---------------------------------------------------------------------------
+
+export type RankTrackingConfig = InferSelectModel;
+
+// ---------------------------------------------------------------------------
+// API / UI types
+// ---------------------------------------------------------------------------
+
+export type RankCheckTriggerResult =
+ | {
+ ok: true;
+ runId: string;
+ }
+ | {
+ ok: false;
+ reason: "already_running";
+ blockingRunId: string | null;
+ };
+
+export interface RankTrackingDeviceResult {
+ position: number | null;
+ previousPosition: number | null;
+ rankingUrl: string | null;
+ serpFeatures: string[];
+}
+
+export interface RankTrackingRow {
+ trackingKeywordId: string;
+ keyword: string;
+ desktop: RankTrackingDeviceResult;
+ mobile: RankTrackingDeviceResult;
+}
+
+// ---------------------------------------------------------------------------
+// Validation schemas
+// ---------------------------------------------------------------------------
+
+const devicesEnum = z.enum(rankTrackingConfigs.devices.enumValues);
+const scheduleEnum = z.enum(rankTrackingConfigs.scheduleInterval.enumValues);
+export const getConfigsSchema = z.object({
+ projectId: z.string().uuid(),
+});
+
+export const createConfigSchema = z.object({
+ projectId: z.string().uuid(),
+ domain: z
+ .string()
+ .min(1)
+ .max(253)
+ .regex(
+ /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/,
+ "Invalid domain format",
+ ),
+ locationCode: z.number().int().positive().optional(),
+ languageCode: z.string().max(10).optional(),
+ devices: devicesEnum.optional(),
+ scheduleInterval: scheduleEnum.optional(),
+});
+
+export const updateConfigSchema = z.object({
+ projectId: z.string().uuid(),
+ configId: z.string().uuid(),
+ domain: z
+ .string()
+ .min(1)
+ .max(253)
+ .regex(
+ /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/,
+ "Invalid domain format",
+ )
+ .optional(),
+ locationCode: z.number().int().positive().optional(),
+ languageCode: z.string().max(10).optional(),
+ devices: devicesEnum.optional(),
+ scheduleInterval: scheduleEnum.optional(),
+ isActive: z.boolean().optional(),
+});
+
+export const triggerCheckSchema = z.object({
+ projectId: z.string().uuid(),
+ configId: z.string().uuid(),
+ keywordIds: z.array(z.string().uuid()).max(2000).optional(),
+});
+
+export const comparePeriodSchema = z.enum(["previous", "7d", "30d", "90d"]);
+export type ComparePeriod = z.infer;
+
+export const getLatestResultsSchema = z.object({
+ projectId: z.string().uuid(),
+ configId: z.string().uuid(),
+ comparePeriod: comparePeriodSchema.optional(),
+});
+
+export const getLatestRunSchema = z.object({
+ projectId: z.string().uuid(),
+ configId: z.string().uuid(),
+});
+
+export const estimateCostSchema = z.object({
+ projectId: z.string().uuid(),
+ configId: z.string().uuid(),
+});
+
+export const addKeywordsSchema = z.object({
+ projectId: z.string().uuid(),
+ configId: z.string().uuid(),
+ keywords: z.array(z.string().min(1).max(200)).min(1).max(2000),
+});
+
+export const removeKeywordsSchema = z.object({
+ projectId: z.string().uuid(),
+ configId: z.string().uuid(),
+ keywordIds: z.array(z.string().uuid()).min(1).max(2000),
+});
diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts
index 275ee46..146e210 100644
--- a/worker-configuration.d.ts
+++ b/worker-configuration.d.ts
@@ -1,5 +1,5 @@
/* eslint-disable */
-// Generated by Wrangler by running `wrangler types` (hash: 0e03c138742d121a834f2931f5295961)
+// Generated by Wrangler by running `wrangler types` (hash: a66fa3c1bf7b028f26292b2e592c2ff1)
// Runtime types generated with workerd@1.20260219.0 2025-09-02 nodejs_compat
declare namespace Cloudflare {
interface GlobalProps {
@@ -9,16 +9,19 @@ declare namespace Cloudflare {
KV: KVNamespace;
R2: R2Bucket;
DB: D1Database;
- VITE_APP_ID: string;
- VITE_GATEWAY_URL: string;
- GATEWAY_URL: string;
- GATEWAY_APP_API_TOKEN: string;
DATAFORSEO_API_KEY: string;
PORT: string;
+ AUTH_MODE: string;
BETTER_AUTH_SECRET: string;
- JWT_PRIVATE_KEY: string;
- JWT_PUBLIC_KEY: string;
+ BETTER_AUTH_URL: string;
+ AUTUMN_SECRET_KEY: string;
+ LOOPS_API_KEY: string;
+ LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID: string;
+ LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID: string;
+ POSTHOG_HOST: string;
+ POSTHOG_PUBLIC_KEY: string;
SITE_AUDIT_WORKFLOW: Workflow[0]['payload']>;
+ RANK_CHECK_WORKFLOW: Workflow[0]['payload']>;
}
}
interface Env extends Cloudflare.Env {}
@@ -26,7 +29,7 @@ type StringifyValues> = {
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
};
declare namespace NodeJS {
- interface ProcessEnv extends StringifyValues> {}
+ interface ProcessEnv extends StringifyValues> {}
}
// Begin runtime types
diff --git a/wrangler.jsonc b/wrangler.jsonc
index ac60244..43ab8e9 100644
--- a/wrangler.jsonc
+++ b/wrangler.jsonc
@@ -23,7 +23,15 @@
"binding": "SITE_AUDIT_WORKFLOW",
"class_name": "SiteAuditWorkflow",
},
+ {
+ "name": "rank-check-workflow",
+ "binding": "RANK_CHECK_WORKFLOW",
+ "class_name": "RankCheckWorkflow",
+ },
],
+ "triggers": {
+ "crons": ["*/15 * * * *"],
+ },
"kv_namespaces": [
{
"binding": "KV",