feat: Rank Tracking MVP (#91)
This commit is contained in:
parent
fb1291537c
commit
53a83996ef
@ -11,7 +11,8 @@
|
|||||||
".output",
|
".output",
|
||||||
"web",
|
"web",
|
||||||
"src/routeTree.gen.ts",
|
"src/routeTree.gen.ts",
|
||||||
"worker-configuration.d.ts"
|
"worker-configuration.d.ts",
|
||||||
|
"scripts"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"react/react-in-jsx-scope": "off",
|
"react/react-in-jsx-scope": "off",
|
||||||
@ -36,7 +37,7 @@
|
|||||||
"eslint/complexity": ["error", { "max": 40 }],
|
"eslint/complexity": ["error", { "max": 40 }],
|
||||||
"eslint/max-lines": [
|
"eslint/max-lines": [
|
||||||
"error",
|
"error",
|
||||||
{ "max": 350, "skipBlankLines": true, "skipComments": true }
|
{ "max": 400, "skipBlankLines": true, "skipComments": true }
|
||||||
],
|
],
|
||||||
"eslint/max-lines-per-function": [
|
"eslint/max-lines-per-function": [
|
||||||
"error",
|
"error",
|
||||||
|
|||||||
27
README.md
27
README.md
@ -30,6 +30,8 @@ OpenSEO is an SEO tool for _the people_. If tools like Semrush or Ahrefs are too
|
|||||||
|
|
||||||
- Keyword research
|
- Keyword research
|
||||||
- Find topics worth targeting, estimate demand, and prioritize what to write next.
|
- 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
|
- Domain insights
|
||||||
- Understand where your domain is gaining or losing visibility so you can focus on the pages that move revenue.
|
- Understand where your domain is gaining or losing visibility so you can focus on the pages that move revenue.
|
||||||
- Backlinks
|
- Backlinks
|
||||||
@ -41,8 +43,9 @@ OpenSEO is an SEO tool for _the people_. If tools like Semrush or Ahrefs are too
|
|||||||
|
|
||||||
Top priorities:
|
Top priorities:
|
||||||
|
|
||||||
- Rank tracking
|
- AI SEO, GEO, LLM Visibility
|
||||||
- AI SEO like LLM Citations
|
- 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.
|
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
|
### 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 Labs pricing: https://dataforseo.com/pricing/dataforseo-labs/dataforseo-google-api
|
||||||
- DataForSEO Backlinks pricing: https://dataforseo.com/pricing/backlinks/backlinks
|
- DataForSEO Backlinks pricing: https://dataforseo.com/pricing/backlinks/backlinks
|
||||||
- DataForSEO Lighthouse API docs: https://docs.dataforseo.com/v3/on_page/lighthouse/overview/
|
- 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):
|
- Current billed cost pattern (from account usage logs):
|
||||||
- `0.02 + (0.0001 x returned_keywords)` USD
|
- `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`
|
- 300 results = `$0.05`
|
||||||
- 500 results = `$0.07`
|
- 500 results = `$0.07`
|
||||||
|
|
||||||
### 3) Domain overview
|
### 4) Domain overview
|
||||||
|
|
||||||
- Standard domain overview request (with top 200 ranked keywords): `$0.0401` per domain.
|
- Standard domain overview request (with top 200 ranked keywords): `$0.0401` per domain.
|
||||||
- General formula if needed:
|
- General formula if needed:
|
||||||
- `0.0201 + (0.0001 x ranked_keywords_returned)` USD
|
- `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.
|
- 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.
|
- Opening extra tabs like `Referring Domains` or `Top Pages` adds about `+$0.02` each.
|
||||||
|
|||||||
67
drizzle/0007_sour_risque.sql
Normal file
67
drizzle/0007_sour_risque.sql
Normal file
@ -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`);
|
||||||
1996
drizzle/meta/0007_snapshot.json
Normal file
1996
drizzle/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -50,6 +50,13 @@
|
|||||||
"when": 1774320825595,
|
"when": 1774320825595,
|
||||||
"tag": "0006_magical_alex_wilder",
|
"tag": "0006_magical_alex_wilder",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1776208279781,
|
||||||
|
"tag": "0007_sour_risque",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
19
src/client/components/Modal.tsx
Normal file
19
src/client/components/Modal.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function Modal({
|
||||||
|
maxWidth = "max-w-sm",
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
maxWidth?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div
|
||||||
|
className={`card bg-base-100 border border-base-300 w-full ${maxWidth} shadow-xl`}
|
||||||
|
>
|
||||||
|
<div className="card-body gap-4">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import {
|
|||||||
type SortDirection,
|
type SortDirection,
|
||||||
type TopPagesTableSort,
|
type TopPagesTableSort,
|
||||||
} from "./backlinksTableSorting";
|
} from "./backlinksTableSorting";
|
||||||
|
|
||||||
export function ReferringDomainsTableHeader({
|
export function ReferringDomainsTableHeader({
|
||||||
sort,
|
sort,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type { BacklinksOverviewData } from "./backlinksPageTypes";
|
import type { BacklinksOverviewData } from "./backlinksPageTypes";
|
||||||
|
|
||||||
export type SortDirection = "asc" | "desc";
|
export type SortDirection = "asc" | "desc";
|
||||||
|
|
||||||
export type ReferringDomainsTableSortField =
|
export type ReferringDomainsTableSortField =
|
||||||
| "domain"
|
| "domain"
|
||||||
| "backlinks"
|
| "backlinks"
|
||||||
@ -50,6 +51,7 @@ export function getNextSort<TField extends string>(
|
|||||||
direction: current.direction === "asc" ? "desc" : "asc",
|
direction: current.direction === "asc" ? "desc" : "asc",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sortReferringDomainRows(
|
export function sortReferringDomainRows(
|
||||||
rows: BacklinksOverviewData["referringDomains"],
|
rows: BacklinksOverviewData["referringDomains"],
|
||||||
sort: ReferringDomainsTableSort,
|
sort: ReferringDomainsTableSort,
|
||||||
|
|||||||
68
src/client/features/rank-tracking/ActionsMenu.tsx
Normal file
68
src/client/features/rank-tracking/ActionsMenu.tsx
Normal file
@ -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 (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-sm gap-1"
|
||||||
|
onClick={() => setOpen((c) => !c)}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
||||||
|
<div className="absolute right-0 top-full mt-1 z-50 rounded-lg border border-base-300 bg-base-100 shadow-lg py-1 min-w-[160px]">
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-base-200"
|
||||||
|
onClick={() => {
|
||||||
|
onCheckNow();
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
disabled={isRunning}
|
||||||
|
>
|
||||||
|
<Play className="size-3.5" />
|
||||||
|
{isRunning ? "Running..." : "Check Now"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-base-200"
|
||||||
|
onClick={() => {
|
||||||
|
onExport();
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
disabled={!hasData}
|
||||||
|
>
|
||||||
|
<Download className="size-3.5" />
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-base-200"
|
||||||
|
onClick={() => {
|
||||||
|
onCopyKeywords();
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
disabled={!hasData}
|
||||||
|
>
|
||||||
|
<Copy className="size-3.5" />
|
||||||
|
Copy Keywords
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/client/features/rank-tracking/AddKeywordsPanel.tsx
Normal file
66
src/client/features/rank-tracking/AddKeywordsPanel.tsx
Normal file
@ -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 (
|
||||||
|
<div className="card bg-base-100 border border-base-300">
|
||||||
|
<div className="card-body gap-3 p-4">
|
||||||
|
<div className="flex gap-2 items-end">
|
||||||
|
<textarea
|
||||||
|
className="textarea textarea-bordered textarea-sm flex-1"
|
||||||
|
rows={3}
|
||||||
|
placeholder="Enter keywords, one per line"
|
||||||
|
value={keywordInput}
|
||||||
|
onChange={(e) => setKeywordInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={() => {
|
||||||
|
const lines = keywordInput
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (lines.length > 0) mutation.mutate(lines);
|
||||||
|
}}
|
||||||
|
disabled={isPending || !keywordInput.trim()}
|
||||||
|
>
|
||||||
|
{isPending && <Loader2 className="size-3 animate-spin" />}
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
src/client/features/rank-tracking/CheckConfirmModal.tsx
Normal file
69
src/client/features/rank-tracking/CheckConfirmModal.tsx
Normal file
@ -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 (
|
||||||
|
<Modal maxWidth="max-w-md">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
Check {keywordCount} keyword
|
||||||
|
{keywordCount !== 1 ? "s" : ""}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-base-content/60 mt-1">
|
||||||
|
{keywordCount} keywords × {dc} device
|
||||||
|
{dc !== 1 ? "s" : ""} = {totalChecks} SERP checks
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-4 rounded-xl border-2 border-base-300 p-4 text-left transition-colors hover:border-primary hover:bg-primary/5"
|
||||||
|
onClick={onRunNow}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<Zap className="size-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium">Run Now</p>
|
||||||
|
<p className="text-xs text-base-content/60">
|
||||||
|
Results in ~
|
||||||
|
{liveTime < 60 ? `${liveTime}s` : `${Math.ceil(liveTime / 60)} min`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-mono font-semibold">${costUsd.toFixed(2)}</p>
|
||||||
|
{isPending && <Loader2 className="size-3 animate-spin ml-auto" />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button className="btn btn-ghost btn-sm self-center" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
220
src/client/features/rank-tracking/RankTrackingConfigModal.tsx
Normal file
220
src/client/features/rank-tracking/RankTrackingConfigModal.tsx
Normal file
@ -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 (
|
||||||
|
<Modal>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">
|
||||||
|
{isEdit ? "Edit Domain Config" : "Add Domain"}
|
||||||
|
</h2>
|
||||||
|
<button className="btn btn-ghost btn-sm btn-square" onClick={onClose}>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-medium">Target Domain</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="example.com"
|
||||||
|
className="input input-bordered w-full"
|
||||||
|
value={domain}
|
||||||
|
onChange={(e) => setDomain(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-medium">Country</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="select select-bordered w-full"
|
||||||
|
value={locationCode}
|
||||||
|
onChange={(e) => setLocationCode(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{LOCATION_OPTIONS.map((loc) => (
|
||||||
|
<option key={loc.code} value={loc.code}>
|
||||||
|
{loc.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-medium">Devices</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="select select-bordered w-full"
|
||||||
|
value={devices}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
if (
|
||||||
|
value === "both" ||
|
||||||
|
value === "desktop" ||
|
||||||
|
value === "mobile"
|
||||||
|
) {
|
||||||
|
setDevices(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="both">Desktop + Mobile</option>
|
||||||
|
<option value="desktop">Desktop only</option>
|
||||||
|
<option value="mobile">Mobile only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-medium">Schedule</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="select select-bordered w-full"
|
||||||
|
value={schedule}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
if (
|
||||||
|
value === "daily" ||
|
||||||
|
value === "weekly" ||
|
||||||
|
value === "manual"
|
||||||
|
) {
|
||||||
|
setSchedule(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="daily">Daily</option>
|
||||||
|
<option value="weekly">Weekly</option>
|
||||||
|
<option value="manual">Manual only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-base-content/60">
|
||||||
|
After adding a domain, manage tracked keywords from the domain detail
|
||||||
|
view.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={isPending || !domain.trim()}
|
||||||
|
>
|
||||||
|
{isPending && <Loader2 className="size-3.5 animate-spin" />}
|
||||||
|
{isEdit ? "Save Changes" : "Add Domain"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
330
src/client/features/rank-tracking/RankTrackingDomainDetail.tsx
Normal file
330
src/client/features/rank-tracking/RankTrackingDomainDetail.tsx
Normal file
@ -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<string> = 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<Filters>(EMPTY_FILTERS);
|
||||||
|
const [comparePeriod, setComparePeriod] = useState<ComparePeriod>("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 (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-xs gap-1 -ml-2 text-base-content/60"
|
||||||
|
onClick={onBack}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-3" />
|
||||||
|
Back to domains
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Domain header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">{config.domain}</h2>
|
||||||
|
<p className="text-xs text-base-content/60">
|
||||||
|
{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</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="btn btn-outline btn-sm gap-1" onClick={onEdit}>
|
||||||
|
<Settings className="size-3.5" />
|
||||||
|
Configure
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-sm gap-1"
|
||||||
|
onClick={() => setShowAddKeywords(!showAddKeywords)}
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
Add Keywords
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.lastSkipReason === "insufficient_credits" && (
|
||||||
|
<div className="alert alert-warning text-sm py-2">
|
||||||
|
<AlertTriangle className="size-4" />
|
||||||
|
<span>
|
||||||
|
Last scheduled check was skipped due to insufficient credits. Top up
|
||||||
|
your balance to resume automatic tracking.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{latestRun?.maybeStale && (
|
||||||
|
<div className="alert alert-warning text-sm py-2">
|
||||||
|
<AlertTriangle className="size-4" />
|
||||||
|
<span>
|
||||||
|
This run may be unresponsive and will be cleaned up automatically.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAddKeywords && (
|
||||||
|
<AddKeywordsPanel
|
||||||
|
configId={config.id}
|
||||||
|
projectId={projectId}
|
||||||
|
onSuccess={handleKeywordsAdded}
|
||||||
|
onCancel={() => setShowAddKeywords(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results card */}
|
||||||
|
<div className="flex-1 flex flex-col min-w-0 border border-base-300 rounded-xl bg-base-100 overflow-hidden">
|
||||||
|
{/* Table toolbar */}
|
||||||
|
<div className="shrink-0 flex items-center gap-2 px-4 py-2 border-b border-base-300">
|
||||||
|
<button
|
||||||
|
className={`btn btn-ghost btn-sm gap-1.5 ${showFilters ? "btn-active" : ""}`}
|
||||||
|
onClick={() => setShowFilters((c) => !c)}
|
||||||
|
title="Toggle table filters"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="size-3.5" />
|
||||||
|
Filters
|
||||||
|
{activeFilterCount > 0 && (
|
||||||
|
<span className="badge badge-xs badge-primary border-0 text-primary-content">
|
||||||
|
{activeFilterCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<select
|
||||||
|
className="select select-bordered select-sm text-xs"
|
||||||
|
value={comparePeriod}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (isComparePeriod(e.target.value))
|
||||||
|
setComparePeriod(e.target.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="previous">vs previous check</option>
|
||||||
|
<option value="7d">vs 7 days ago</option>
|
||||||
|
<option value="30d">vs 30 days ago</option>
|
||||||
|
<option value="90d">vs 90 days ago</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{isRunning && latestRun ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-base-content/70">
|
||||||
|
<Loader2 className="size-3.5 animate-spin text-primary" />
|
||||||
|
<span>
|
||||||
|
{latestRun.status === "pending"
|
||||||
|
? "Preparing..."
|
||||||
|
: "Checking keywords..."}{" "}
|
||||||
|
{latestRun.keywordsChecked}/{latestRun.keywordsTotal || "?"}
|
||||||
|
</span>
|
||||||
|
{latestRun.keywordsTotal > 0 && (
|
||||||
|
<progress
|
||||||
|
className="progress progress-primary w-24"
|
||||||
|
value={latestRun.keywordsChecked}
|
||||||
|
max={latestRun.keywordsTotal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-base-content/60">
|
||||||
|
{filtered.length} keywords
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<ActionsMenu
|
||||||
|
onCheckNow={() => {
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters panel */}
|
||||||
|
{showFilters && (
|
||||||
|
<FilterPanel
|
||||||
|
filters={filters}
|
||||||
|
setFilters={setFilters}
|
||||||
|
activeFilterCount={activeFilterCount}
|
||||||
|
onReset={() => setFilters(EMPTY_FILTERS)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="p-4">
|
||||||
|
<RankTrackingTable
|
||||||
|
totalCount={rows.length}
|
||||||
|
sorted={sorted}
|
||||||
|
resultsLoading={resultsLoading}
|
||||||
|
showDesktop={showDesktop}
|
||||||
|
showMobile={showMobile}
|
||||||
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
domain={config.domain}
|
||||||
|
configId={config.id}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pendingCheck && (
|
||||||
|
<CheckConfirmModal
|
||||||
|
keywordCount={pendingCheck.count}
|
||||||
|
devices={config.devices}
|
||||||
|
isPending={isPending}
|
||||||
|
onRunNow={() =>
|
||||||
|
startCheck({
|
||||||
|
keywordIds: pendingCheck.keywordIds,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onCancel={() => setPendingCheck(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
src/client/features/rank-tracking/RankTrackingDomainList.tsx
Normal file
106
src/client/features/rank-tracking/RankTrackingDomainList.tsx
Normal file
@ -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<typeof getRankTrackingConfigSummaries>
|
||||||
|
>[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 (
|
||||||
|
<div className="card bg-base-100 border border-base-300">
|
||||||
|
<div className="card-body gap-0 p-0">
|
||||||
|
<div className="flex items-center justify-between px-5 pt-4 pb-3">
|
||||||
|
<h2 className="text-sm font-semibold">Tracked Domains</h2>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-sm btn-outline gap-1"
|
||||||
|
onClick={onAddDomain}
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
Add Domain
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-base-300">
|
||||||
|
{(summaries ?? []).map((summary) => (
|
||||||
|
<DomainRow
|
||||||
|
key={summary.id}
|
||||||
|
summary={summary}
|
||||||
|
onClick={() => onSelectConfig(summary.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DomainRow({
|
||||||
|
summary,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
summary: ConfigSummary;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const dl = getDevicesLabel(summary.devices);
|
||||||
|
const sl = getScheduleLabel(summary.scheduleInterval);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-4 px-5 py-3.5 text-left transition-colors hover:bg-base-200/50"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-base-200">
|
||||||
|
<Globe className="size-4 text-base-content/60" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium truncate">{summary.domain}</p>
|
||||||
|
<p className="text-xs text-base-content/60">
|
||||||
|
{LOCATIONS[summary.locationCode] ?? "US"} · {dl} · {sl}
|
||||||
|
{summary.lastRunCompletedAt && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
· Last:{" "}
|
||||||
|
{new Date(summary.lastRunCompletedAt).toLocaleDateString()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{summary.lastSkipReason === "insufficient_credits" && (
|
||||||
|
<p className="flex items-center gap-1 text-xs text-warning">
|
||||||
|
<AlertTriangle className="size-3" />
|
||||||
|
Scheduled check skipped — insufficient credits
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:flex items-center gap-6 text-sm">
|
||||||
|
{summary.keywordCount > 0 && (
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-base-content/60">
|
||||||
|
Keywords
|
||||||
|
</p>
|
||||||
|
<p className="font-mono font-medium">{summary.keywordCount}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="size-4 shrink-0 text-base-content/40" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
193
src/client/features/rank-tracking/RankTrackingFilters.tsx
Normal file
193
src/client/features/rank-tracking/RankTrackingFilters.tsx
Normal file
@ -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 (
|
||||||
|
<div className="shrink-0 border-b border-base-300 bg-gradient-to-b from-base-100 to-base-200/30 px-4 py-3 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-semibold">Refine results</p>
|
||||||
|
{activeFilterCount > 0 && (
|
||||||
|
<span className="badge badge-xs badge-primary border-0 text-primary-content">
|
||||||
|
{activeFilterCount} active
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-xs btn-ghost gap-1"
|
||||||
|
onClick={onReset}
|
||||||
|
disabled={activeFilterCount === 0}
|
||||||
|
>
|
||||||
|
<RotateCcw className="size-3" />
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
|
||||||
|
Include
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
className="input input-bordered input-sm w-full bg-base-100"
|
||||||
|
placeholder="e.g. seo, tool"
|
||||||
|
value={filters.include}
|
||||||
|
onChange={(e) => update("include", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
|
||||||
|
Exclude
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
className="input input-bordered input-sm w-full bg-base-100"
|
||||||
|
placeholder="e.g. free, cheap"
|
||||||
|
value={filters.exclude}
|
||||||
|
onChange={(e) => update("exclude", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||||
|
<RangeFilter
|
||||||
|
title="Desktop position"
|
||||||
|
minValue={filters.minDesktopPos}
|
||||||
|
maxValue={filters.maxDesktopPos}
|
||||||
|
onMinChange={(v) => update("minDesktopPos", v)}
|
||||||
|
onMaxChange={(v) => update("maxDesktopPos", v)}
|
||||||
|
/>
|
||||||
|
<RangeFilter
|
||||||
|
title="Mobile position"
|
||||||
|
minValue={filters.minMobilePos}
|
||||||
|
maxValue={filters.maxMobilePos}
|
||||||
|
onMinChange={(v) => update("minMobilePos", v)}
|
||||||
|
onMaxChange={(v) => update("maxMobilePos", v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RangeFilter({
|
||||||
|
title,
|
||||||
|
minValue,
|
||||||
|
maxValue,
|
||||||
|
onMinChange,
|
||||||
|
onMaxChange,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
minValue: string;
|
||||||
|
maxValue: string;
|
||||||
|
onMinChange: (v: string) => void;
|
||||||
|
onMaxChange: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-base-300 bg-base-100 p-2.5 space-y-2">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<input
|
||||||
|
className="input input-bordered input-xs bg-base-100"
|
||||||
|
placeholder="Min"
|
||||||
|
type="number"
|
||||||
|
value={minValue}
|
||||||
|
onChange={(e) => onMinChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="input input-bordered input-xs bg-base-100"
|
||||||
|
placeholder="Max"
|
||||||
|
type="number"
|
||||||
|
value={maxValue}
|
||||||
|
onChange={(e) => onMaxChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
107
src/client/features/rank-tracking/RankTrackingPage.tsx
Normal file
107
src/client/features/rank-tracking/RankTrackingPage.tsx
Normal file
@ -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<string | null>(null);
|
||||||
|
const [showConfigModal, setShowConfigModal] = useState(false);
|
||||||
|
const [editingConfig, setEditingConfig] = useState<RankTrackingConfig | null>(
|
||||||
|
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 (
|
||||||
|
<div className="px-4 py-4 pb-24 overflow-auto md:px-6 md:py-6 md:pb-8">
|
||||||
|
<div className="mx-auto max-w-7xl space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Rank Tracking</h1>
|
||||||
|
<p className="text-sm text-base-content/70">
|
||||||
|
Track keyword positions across domains
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center p-12">
|
||||||
|
<Loader2 className="size-6 animate-spin text-base-content/50" />
|
||||||
|
</div>
|
||||||
|
) : !configs || configs.length === 0 ? (
|
||||||
|
<section className="rounded-2xl border border-dashed border-base-300 bg-base-100/70 p-8 text-center space-y-3">
|
||||||
|
<div className="mx-auto flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||||
|
<TrendingUp className="size-6" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-medium text-base-content/80">
|
||||||
|
Track your keyword positions over time
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-base-content/55 max-w-md mx-auto">
|
||||||
|
Monitor rankings for your saved keywords, see position changes,
|
||||||
|
and spot trends across desktop and mobile.
|
||||||
|
</p>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={openAddModal}>
|
||||||
|
Add Domain
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-base-content/50">
|
||||||
|
Requires saved keywords in this project.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
) : selectedConfig ? (
|
||||||
|
<RankTrackingDomainDetail
|
||||||
|
config={selectedConfig}
|
||||||
|
projectId={projectId}
|
||||||
|
onBack={() => setSelectedConfigId(null)}
|
||||||
|
onEdit={() => openEditModal(selectedConfig)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<RankTrackingDomainList
|
||||||
|
projectId={projectId}
|
||||||
|
onSelectConfig={setSelectedConfigId}
|
||||||
|
onAddDomain={openAddModal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showConfigModal && (
|
||||||
|
<RankTrackingConfigModal
|
||||||
|
projectId={projectId}
|
||||||
|
existingConfig={editingConfig}
|
||||||
|
onClose={() => setShowConfigModal(false)}
|
||||||
|
onSaved={() => {
|
||||||
|
setShowConfigModal(false);
|
||||||
|
invalidateConfigs();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
245
src/client/features/rank-tracking/RankTrackingTable.tsx
Normal file
245
src/client/features/rank-tracking/RankTrackingTable.tsx
Normal file
@ -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<Set<string>>(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 (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<Loader2 className="size-5 animate-spin text-base-content/50" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-dashed border-base-300 p-10 text-center text-sm text-base-content/55">
|
||||||
|
{totalCount === 0
|
||||||
|
? 'No rank data yet. Click "Check Now" to run your first check.'
|
||||||
|
: "No keywords match your search."}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allSelected =
|
||||||
|
visibleSelectedCount === sorted.length && sorted.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Bulk action bar */}
|
||||||
|
{visibleSelectedCount > 0 && (
|
||||||
|
<div className="flex items-center gap-3 rounded-lg bg-base-200 px-3 py-2 text-sm">
|
||||||
|
<span className="text-base-content/70">
|
||||||
|
{visibleSelectedCount} keyword
|
||||||
|
{visibleSelectedCount !== 1 ? "s" : ""} selected
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="btn btn-error btn-xs gap-1"
|
||||||
|
onClick={() => setShowConfirm(true)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3" />
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-xs"
|
||||||
|
onClick={() => setSelected(new Set())}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirm modal */}
|
||||||
|
{showConfirm && (
|
||||||
|
<Modal>
|
||||||
|
<h3 className="text-lg font-semibold">Remove keywords?</h3>
|
||||||
|
<p className="text-sm text-base-content/70">
|
||||||
|
This will stop tracking {visibleSelectedCount} keyword
|
||||||
|
{visibleSelectedCount !== 1 ? "s" : ""}. Historical ranking data is
|
||||||
|
preserved but won't appear in the table.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => setShowConfirm(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-error btn-sm gap-1"
|
||||||
|
onClick={() => removeMutation.mutate([...visibleSelected])}
|
||||||
|
disabled={removeMutation.isPending}
|
||||||
|
>
|
||||||
|
{removeMutation.isPending && (
|
||||||
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
)}
|
||||||
|
Remove {visibleSelectedCount} keyword
|
||||||
|
{visibleSelectedCount !== 1 ? "s" : ""}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="w-8">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox checkbox-xs"
|
||||||
|
checked={allSelected}
|
||||||
|
onChange={toggleAll}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<SortableHeader
|
||||||
|
label="Keyword"
|
||||||
|
field="keyword"
|
||||||
|
currentField={sortField}
|
||||||
|
currentDir={sortDir}
|
||||||
|
onClick={onSort}
|
||||||
|
/>
|
||||||
|
{showDesktop && (
|
||||||
|
<SortableHeader
|
||||||
|
label="Desktop"
|
||||||
|
field="desktopPosition"
|
||||||
|
currentField={sortField}
|
||||||
|
currentDir={sortDir}
|
||||||
|
onClick={onSort}
|
||||||
|
className="min-w-44"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showMobile && (
|
||||||
|
<SortableHeader
|
||||||
|
label="Mobile"
|
||||||
|
field="mobilePosition"
|
||||||
|
currentField={sortField}
|
||||||
|
currentDir={sortDir}
|
||||||
|
onClick={onSort}
|
||||||
|
className="min-w-44"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sorted.map((row) => (
|
||||||
|
<tr key={row.trackingKeywordId}>
|
||||||
|
<td className="w-8">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox checkbox-xs"
|
||||||
|
checked={selected.has(row.trackingKeywordId)}
|
||||||
|
onChange={() => toggleSelect(row.trackingKeywordId)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="font-medium">{row.keyword}</td>
|
||||||
|
{showDesktop && (
|
||||||
|
<td className="align-top">
|
||||||
|
<DeviceRankCell result={row.desktop} domain={domain} />
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{showMobile && (
|
||||||
|
<td className="align-top">
|
||||||
|
<DeviceRankCell result={row.mobile} domain={domain} />
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-base-content/60 pt-2">
|
||||||
|
{sorted.length} of {totalCount} keywords
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
265
src/client/features/rank-tracking/RankTrackingTableParts.tsx
Normal file
265
src/client/features/rank-tracking/RankTrackingTableParts.tsx
Normal file
@ -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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<th
|
||||||
|
className={`cursor-pointer select-none text-xs uppercase tracking-wide text-base-content/60 hover:text-base-content ${className}`}
|
||||||
|
onClick={() => onClick(field)}
|
||||||
|
title={HEADER_TOOLTIPS[field]}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{isActive && (
|
||||||
|
<span className="ml-1">{currentDir === "asc" ? "↑" : "↓"}</span>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PositionBadge({ position }: { position: number | null }) {
|
||||||
|
if (position === null) {
|
||||||
|
return <span className="text-base-content/40">-</span>;
|
||||||
|
}
|
||||||
|
return <span className="font-mono">{position}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChangeIndicator({
|
||||||
|
current,
|
||||||
|
previous,
|
||||||
|
}: {
|
||||||
|
current: number | null;
|
||||||
|
previous: number | null;
|
||||||
|
}) {
|
||||||
|
if (previous === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (current === null) {
|
||||||
|
return <span className="badge badge-xs badge-error">lost</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const change = previous - current; // positive = improved (lower position number is better)
|
||||||
|
if (change > 0) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-0.5 text-xs text-success">
|
||||||
|
<ArrowUp className="size-3" />+{change}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (change < 0) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-0.5 text-xs text-error">
|
||||||
|
<ArrowDown className="size-3" />
|
||||||
|
{change}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="text-base-content/40">
|
||||||
|
<Minus className="size-3 inline" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FEATURE_SHORT_LABELS: Record<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{notable.map((f) => (
|
||||||
|
<span
|
||||||
|
key={f}
|
||||||
|
className="badge badge-outline badge-xs gap-0.5 cursor-help"
|
||||||
|
title={FEATURE_TOOLTIPS[f] ?? f}
|
||||||
|
>
|
||||||
|
{f === "ai_overview" && <Sparkles className="size-2.5" />}
|
||||||
|
{FEATURE_SHORT_LABELS[f]}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PositionWithChange({
|
||||||
|
position,
|
||||||
|
previous,
|
||||||
|
}: {
|
||||||
|
position: number | null;
|
||||||
|
previous: number | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex w-full items-center justify-between px-3">
|
||||||
|
<PositionBadge position={position} />
|
||||||
|
<ChangeIndicator current={position} previous={previous} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeviceRankCell({
|
||||||
|
result,
|
||||||
|
domain,
|
||||||
|
}: {
|
||||||
|
result: RankTrackingDeviceResult;
|
||||||
|
domain: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-44 space-y-1.5">
|
||||||
|
<PositionWithChange
|
||||||
|
position={result.position}
|
||||||
|
previous={result.previousPosition}
|
||||||
|
/>
|
||||||
|
{result.rankingUrl ? (
|
||||||
|
<a
|
||||||
|
href={toFullUrl(result.rankingUrl, domain)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="link link-hover block truncate px-3 text-xs"
|
||||||
|
title={result.rankingUrl}
|
||||||
|
>
|
||||||
|
{toPath(result.rankingUrl)}
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
{result.serpFeatures.length > 0 ? (
|
||||||
|
<div className="px-3">
|
||||||
|
<SerpFeatureTags features={result.serpFeatures} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
59
src/client/features/rank-tracking/useRankCheckTrigger.ts
Normal file
59
src/client/features/rank-tracking/useRankCheckTrigger.ts
Normal file
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
39
src/client/features/rank-tracking/useRankRunPolling.ts
Normal file
39
src/client/features/rank-tracking/useRankRunPolling.ts
Normal file
@ -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<string | undefined>(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;
|
||||||
|
}
|
||||||
37
src/client/features/rank-tracking/useRankTableSort.ts
Normal file
37
src/client/features/rank-tracking/useRankTableSort.ts
Normal file
@ -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<SortField>(defaultField);
|
||||||
|
const [sortDir, setSortDir] = useState<SortDir>("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 };
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import {
|
|||||||
Globe,
|
Globe,
|
||||||
Link2,
|
Link2,
|
||||||
Search,
|
Search,
|
||||||
|
TrendingUp,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { linkOptions } from "@tanstack/react-router";
|
import { linkOptions } from "@tanstack/react-router";
|
||||||
|
|
||||||
@ -21,6 +22,12 @@ const projectNavItems = [
|
|||||||
icon: Bookmark,
|
icon: Bookmark,
|
||||||
matchSegment: "/saved",
|
matchSegment: "/saved",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: "/p/$projectId/rank-tracking" as const,
|
||||||
|
label: "Rank Tracking",
|
||||||
|
icon: TrendingUp,
|
||||||
|
matchSegment: "/rank-tracking",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
to: "/p/$projectId/domain" as const,
|
to: "/p/$projectId/domain" as const,
|
||||||
label: "Domain Overview",
|
label: "Domain Overview",
|
||||||
|
|||||||
@ -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
|
// Site Audit tables
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@ -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 ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route'
|
||||||
import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index'
|
import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index'
|
||||||
import { Route as ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved'
|
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 ProjectPProjectIdKeywordsRouteImport } from './routes/_project/p/$projectId/keywords'
|
||||||
import { Route as ProjectPProjectIdDomainRouteImport } from './routes/_project/p/$projectId/domain'
|
import { Route as ProjectPProjectIdDomainRouteImport } from './routes/_project/p/$projectId/domain'
|
||||||
import { Route as ProjectPProjectIdBacklinksRouteImport } from './routes/_project/p/$projectId/backlinks'
|
import { Route as ProjectPProjectIdBacklinksRouteImport } from './routes/_project/p/$projectId/backlinks'
|
||||||
@ -127,6 +128,12 @@ const ProjectPProjectIdSavedRoute = ProjectPProjectIdSavedRouteImport.update({
|
|||||||
path: '/saved',
|
path: '/saved',
|
||||||
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ProjectPProjectIdRankTrackingRoute =
|
||||||
|
ProjectPProjectIdRankTrackingRouteImport.update({
|
||||||
|
id: '/rank-tracking',
|
||||||
|
path: '/rank-tracking',
|
||||||
|
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
||||||
|
} as any)
|
||||||
const ProjectPProjectIdKeywordsRoute =
|
const ProjectPProjectIdKeywordsRoute =
|
||||||
ProjectPProjectIdKeywordsRouteImport.update({
|
ProjectPProjectIdKeywordsRouteImport.update({
|
||||||
id: '/keywords',
|
id: '/keywords',
|
||||||
@ -186,6 +193,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||||
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
||||||
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
||||||
|
'/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRoute
|
||||||
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
||||||
'/p/$projectId/': typeof ProjectPProjectIdIndexRoute
|
'/p/$projectId/': typeof ProjectPProjectIdIndexRoute
|
||||||
'/p/$projectId/audit/': typeof ProjectPProjectIdAuditIndexRoute
|
'/p/$projectId/audit/': typeof ProjectPProjectIdAuditIndexRoute
|
||||||
@ -208,6 +216,7 @@ export interface FileRoutesByTo {
|
|||||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||||
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
||||||
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
||||||
|
'/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRoute
|
||||||
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
||||||
'/p/$projectId': typeof ProjectPProjectIdIndexRoute
|
'/p/$projectId': typeof ProjectPProjectIdIndexRoute
|
||||||
'/p/$projectId/audit': typeof ProjectPProjectIdAuditIndexRoute
|
'/p/$projectId/audit': typeof ProjectPProjectIdAuditIndexRoute
|
||||||
@ -237,6 +246,7 @@ export interface FileRoutesById {
|
|||||||
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||||
'/_project/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
'/_project/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
||||||
'/_project/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
'/_project/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
||||||
|
'/_project/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRoute
|
||||||
'/_project/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
'/_project/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
||||||
'/_project/p/$projectId/': typeof ProjectPProjectIdIndexRoute
|
'/_project/p/$projectId/': typeof ProjectPProjectIdIndexRoute
|
||||||
'/_project/p/$projectId/audit/': typeof ProjectPProjectIdAuditIndexRoute
|
'/_project/p/$projectId/audit/': typeof ProjectPProjectIdAuditIndexRoute
|
||||||
@ -263,6 +273,7 @@ export interface FileRouteTypes {
|
|||||||
| '/p/$projectId/backlinks'
|
| '/p/$projectId/backlinks'
|
||||||
| '/p/$projectId/domain'
|
| '/p/$projectId/domain'
|
||||||
| '/p/$projectId/keywords'
|
| '/p/$projectId/keywords'
|
||||||
|
| '/p/$projectId/rank-tracking'
|
||||||
| '/p/$projectId/saved'
|
| '/p/$projectId/saved'
|
||||||
| '/p/$projectId/'
|
| '/p/$projectId/'
|
||||||
| '/p/$projectId/audit/'
|
| '/p/$projectId/audit/'
|
||||||
@ -285,6 +296,7 @@ export interface FileRouteTypes {
|
|||||||
| '/p/$projectId/backlinks'
|
| '/p/$projectId/backlinks'
|
||||||
| '/p/$projectId/domain'
|
| '/p/$projectId/domain'
|
||||||
| '/p/$projectId/keywords'
|
| '/p/$projectId/keywords'
|
||||||
|
| '/p/$projectId/rank-tracking'
|
||||||
| '/p/$projectId/saved'
|
| '/p/$projectId/saved'
|
||||||
| '/p/$projectId'
|
| '/p/$projectId'
|
||||||
| '/p/$projectId/audit'
|
| '/p/$projectId/audit'
|
||||||
@ -313,6 +325,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_project/p/$projectId/backlinks'
|
| '/_project/p/$projectId/backlinks'
|
||||||
| '/_project/p/$projectId/domain'
|
| '/_project/p/$projectId/domain'
|
||||||
| '/_project/p/$projectId/keywords'
|
| '/_project/p/$projectId/keywords'
|
||||||
|
| '/_project/p/$projectId/rank-tracking'
|
||||||
| '/_project/p/$projectId/saved'
|
| '/_project/p/$projectId/saved'
|
||||||
| '/_project/p/$projectId/'
|
| '/_project/p/$projectId/'
|
||||||
| '/_project/p/$projectId/audit/'
|
| '/_project/p/$projectId/audit/'
|
||||||
@ -466,6 +479,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ProjectPProjectIdSavedRouteImport
|
preLoaderRoute: typeof ProjectPProjectIdSavedRouteImport
|
||||||
parentRoute: typeof ProjectPProjectIdRouteRoute
|
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': {
|
'/_project/p/$projectId/keywords': {
|
||||||
id: '/_project/p/$projectId/keywords'
|
id: '/_project/p/$projectId/keywords'
|
||||||
path: '/keywords'
|
path: '/keywords'
|
||||||
@ -559,6 +579,7 @@ interface ProjectPProjectIdRouteRouteChildren {
|
|||||||
ProjectPProjectIdBacklinksRoute: typeof ProjectPProjectIdBacklinksRoute
|
ProjectPProjectIdBacklinksRoute: typeof ProjectPProjectIdBacklinksRoute
|
||||||
ProjectPProjectIdDomainRoute: typeof ProjectPProjectIdDomainRoute
|
ProjectPProjectIdDomainRoute: typeof ProjectPProjectIdDomainRoute
|
||||||
ProjectPProjectIdKeywordsRoute: typeof ProjectPProjectIdKeywordsRoute
|
ProjectPProjectIdKeywordsRoute: typeof ProjectPProjectIdKeywordsRoute
|
||||||
|
ProjectPProjectIdRankTrackingRoute: typeof ProjectPProjectIdRankTrackingRoute
|
||||||
ProjectPProjectIdSavedRoute: typeof ProjectPProjectIdSavedRoute
|
ProjectPProjectIdSavedRoute: typeof ProjectPProjectIdSavedRoute
|
||||||
ProjectPProjectIdIndexRoute: typeof ProjectPProjectIdIndexRoute
|
ProjectPProjectIdIndexRoute: typeof ProjectPProjectIdIndexRoute
|
||||||
}
|
}
|
||||||
@ -570,6 +591,7 @@ const ProjectPProjectIdRouteRouteChildren: ProjectPProjectIdRouteRouteChildren =
|
|||||||
ProjectPProjectIdBacklinksRoute: ProjectPProjectIdBacklinksRoute,
|
ProjectPProjectIdBacklinksRoute: ProjectPProjectIdBacklinksRoute,
|
||||||
ProjectPProjectIdDomainRoute: ProjectPProjectIdDomainRoute,
|
ProjectPProjectIdDomainRoute: ProjectPProjectIdDomainRoute,
|
||||||
ProjectPProjectIdKeywordsRoute: ProjectPProjectIdKeywordsRoute,
|
ProjectPProjectIdKeywordsRoute: ProjectPProjectIdKeywordsRoute,
|
||||||
|
ProjectPProjectIdRankTrackingRoute: ProjectPProjectIdRankTrackingRoute,
|
||||||
ProjectPProjectIdSavedRoute: ProjectPProjectIdSavedRoute,
|
ProjectPProjectIdSavedRoute: ProjectPProjectIdSavedRoute,
|
||||||
ProjectPProjectIdIndexRoute: ProjectPProjectIdIndexRoute,
|
ProjectPProjectIdIndexRoute: ProjectPProjectIdIndexRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
11
src/routes/_project/p/$projectId/rank-tracking.tsx
Normal file
11
src/routes/_project/p/$projectId/rank-tracking.tsx
Normal file
@ -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 <RankTrackingPage projectId={projectId} />;
|
||||||
|
}
|
||||||
@ -6,7 +6,14 @@ import {
|
|||||||
getSavedKeywords,
|
getSavedKeywords,
|
||||||
removeSavedKeyword,
|
removeSavedKeyword,
|
||||||
} from "@/serverFunctions/keywords";
|
} 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 { buildCsv, downloadCsv } from "@/client/lib/csv";
|
||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
import { captureClientEvent } from "@/client/lib/posthog";
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
@ -15,17 +22,30 @@ export const Route = createFileRoute("/_project/p/$projectId/saved")({
|
|||||||
component: SavedKeywordsPage,
|
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() {
|
function SavedKeywordsPage() {
|
||||||
const { projectId } = Route.useParams();
|
const { projectId } = Route.useParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [removeError, setRemoveError] = useState<string | null>(null);
|
const [removeError, setRemoveError] = useState<string | null>(null);
|
||||||
const [removingId, setRemovingId] = useState<string | null>(null);
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
const { data: savedKeywordsData, isLoading } = useQuery({
|
const { data: savedKeywordsData, isLoading } = useQuery({
|
||||||
queryKey: ["savedKeywords", projectId],
|
queryKey: ["savedKeywords", projectId],
|
||||||
queryFn: () => getSavedKeywords({ data: { projectId } }),
|
queryFn: () => getSavedKeywords({ data: { projectId } }),
|
||||||
});
|
});
|
||||||
const savedKeywords = savedKeywordsData?.rows ?? [];
|
const savedKeywords: SavedKeyword[] = savedKeywordsData?.rows ?? [];
|
||||||
|
|
||||||
const removeMutation = useMutation({
|
const removeMutation = useMutation({
|
||||||
mutationFn: (savedKeywordId: string) =>
|
mutationFn: (savedKeywordId: string) =>
|
||||||
@ -42,16 +62,54 @@ function SavedKeywordsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleRemoveKeyword = (savedKeywordId: string) => {
|
const handleDeleteSelected = async () => {
|
||||||
|
setDeleting(true);
|
||||||
setRemoveError(null);
|
setRemoveError(null);
|
||||||
setRemovingId(savedKeywordId);
|
const ids = [...selected];
|
||||||
removeMutation.mutate(savedKeywordId, {
|
for (const id of ids) {
|
||||||
onSettled: () => {
|
try {
|
||||||
setRemovingId((current) =>
|
await removeMutation.mutateAsync(id);
|
||||||
current === savedKeywordId ? null : current,
|
} 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 = () => {
|
const exportCsv = () => {
|
||||||
@ -59,7 +117,6 @@ function SavedKeywordsPage() {
|
|||||||
toast.error("No keywords to export");
|
toast.error("No keywords to export");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = [
|
const headers = [
|
||||||
"Keyword",
|
"Keyword",
|
||||||
"Volume",
|
"Volume",
|
||||||
@ -86,42 +143,6 @@ function SavedKeywordsPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
|
||||||
<SavedKeywordsContent
|
|
||||||
isLoading={isLoading}
|
|
||||||
removeError={removeError}
|
|
||||||
removingId={removingId}
|
|
||||||
savedKeywords={savedKeywords}
|
|
||||||
onExportCsv={exportCsv}
|
|
||||||
onRemoveKeyword={handleRemoveKeyword}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto">
|
<div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto">
|
||||||
<div className="mx-auto max-w-5xl space-y-4">
|
<div className="mx-auto max-w-5xl space-y-4">
|
||||||
@ -133,7 +154,7 @@ function SavedKeywordsContent({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{savedKeywords.length > 0 && (
|
{savedKeywords.length > 0 && (
|
||||||
<button className="btn btn-sm" onClick={onExportCsv}>
|
<button className="btn btn-sm" onClick={exportCsv}>
|
||||||
<Download className="size-4" /> Export CSV
|
<Download className="size-4" /> Export CSV
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@ -178,46 +199,57 @@ function SavedKeywordsContent({
|
|||||||
<span>{removeError}</span>
|
<span>{removeError}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* Bulk action bar or keyword count */}
|
||||||
|
{selected.size > 0 ? (
|
||||||
|
<div className="flex items-center gap-3 rounded-lg bg-base-200 px-3 py-2 text-sm">
|
||||||
|
<span className="text-base-content/70">
|
||||||
|
{selected.size} keyword
|
||||||
|
{selected.size !== 1 ? "s" : ""} selected
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-xs gap-1"
|
||||||
|
onClick={handleCopySelected}
|
||||||
|
>
|
||||||
|
<Copy className="size-3" />
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-error btn-xs gap-1"
|
||||||
|
onClick={() => setShowConfirm(true)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-xs"
|
||||||
|
onClick={() => setSelected(new Set())}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<p className="text-sm text-base-content/70">
|
<p className="text-sm text-base-content/70">
|
||||||
{savedKeywords.length} saved keyword
|
{savedKeywords.length} saved keyword
|
||||||
{savedKeywords.length !== 1 ? "s" : ""}
|
{savedKeywords.length !== 1 ? "s" : ""}
|
||||||
</p>
|
</p>
|
||||||
<SavedKeywordsTable
|
|
||||||
rows={savedKeywords}
|
|
||||||
removingId={removingId}
|
|
||||||
onRemoveKeyword={onRemoveKeyword}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="table table-zebra table-sm">
|
<table className="table table-zebra table-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th className="w-8">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox checkbox-xs"
|
||||||
|
checked={
|
||||||
|
selected.size === savedKeywords.length &&
|
||||||
|
savedKeywords.length > 0
|
||||||
|
}
|
||||||
|
onChange={toggleAll}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
<th>Keyword</th>
|
<th>Keyword</th>
|
||||||
<th>Volume</th>
|
<th>Volume</th>
|
||||||
<th>CPC</th>
|
<th>CPC</th>
|
||||||
@ -225,17 +257,28 @@ function SavedKeywordsTable({
|
|||||||
<th>Difficulty</th>
|
<th>Difficulty</th>
|
||||||
<th>Intent</th>
|
<th>Intent</th>
|
||||||
<th>Last Fetched</th>
|
<th>Last Fetched</th>
|
||||||
<th></th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((kw) => (
|
{savedKeywords.map((kw) => (
|
||||||
<tr key={kw.id}>
|
<tr key={kw.id}>
|
||||||
|
<td className="w-8">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox checkbox-xs"
|
||||||
|
checked={selected.has(kw.id)}
|
||||||
|
onChange={() => toggleSelect(kw.id)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td className="font-medium">{kw.keyword}</td>
|
<td className="font-medium">{kw.keyword}</td>
|
||||||
<td>{formatNumber(kw.searchVolume)}</td>
|
<td>{formatNumber(kw.searchVolume)}</td>
|
||||||
<td>{kw.cpc == null ? "-" : `$${kw.cpc.toFixed(2)}`}</td>
|
|
||||||
<td>
|
<td>
|
||||||
{kw.competition == null ? "-" : kw.competition.toFixed(2)}
|
{kw.cpc == null ? "-" : `$${kw.cpc.toFixed(2)}`}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{kw.competition == null
|
||||||
|
? "-"
|
||||||
|
: kw.competition.toFixed(2)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<DifficultyBadge value={kw.keywordDifficulty} />
|
<DifficultyBadge value={kw.keywordDifficulty} />
|
||||||
@ -250,25 +293,48 @@ function SavedKeywordsTable({
|
|||||||
? new Date(kw.fetchedAt).toLocaleDateString()
|
? new Date(kw.fetchedAt).toLocaleDateString()
|
||||||
: "-"}
|
: "-"}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
className="btn btn-ghost btn-xs text-error"
|
|
||||||
onClick={() => onRemoveKeyword(kw.id)}
|
|
||||||
disabled={removingId === kw.id}
|
|
||||||
title="Remove"
|
|
||||||
>
|
|
||||||
{removingId === kw.id ? (
|
|
||||||
<Loader2 className="size-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2 className="size-3" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirm delete modal */}
|
||||||
|
{showConfirm && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="card bg-base-100 border border-base-300 w-full max-w-sm shadow-xl">
|
||||||
|
<div className="card-body gap-4">
|
||||||
|
<h3 className="text-lg font-semibold">Delete keywords?</h3>
|
||||||
|
<p className="text-sm text-base-content/70">
|
||||||
|
This will permanently delete {selected.size} saved keyword
|
||||||
|
{selected.size !== 1 ? "s" : ""}.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => setShowConfirm(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-error btn-sm gap-1"
|
||||||
|
onClick={() => void handleDeleteSelected()}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
{deleting && <Loader2 className="size-3 animate-spin" />}
|
||||||
|
Delete {selected.size} keyword
|
||||||
|
{selected.size !== 1 ? "s" : ""}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
107
src/server.ts
107
src/server.ts
@ -2,12 +2,119 @@ import {
|
|||||||
createStartHandler,
|
createStartHandler,
|
||||||
defaultStreamHandler,
|
defaultStreamHandler,
|
||||||
} from "@tanstack/react-start/server";
|
} 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);
|
const fetch = createStartHandler(defaultStreamHandler);
|
||||||
|
|
||||||
// Export Workflow classes as named exports
|
// Export Workflow classes as named exports
|
||||||
export { SiteAuditWorkflow } from "./server/workflows/SiteAuditWorkflow";
|
export { SiteAuditWorkflow } from "./server/workflows/SiteAuditWorkflow";
|
||||||
|
export { RankCheckWorkflow } from "./server/workflows/RankCheckWorkflow";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
fetch,
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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<typeof db.batch>[0][number];
|
||||||
|
|
||||||
|
async function executeInBatches<T>(
|
||||||
|
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<typeof rankTrackingConfigs>,
|
||||||
|
) {
|
||||||
|
await db.insert(rankTrackingConfigs).values(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateConfig(
|
||||||
|
configId: string,
|
||||||
|
projectId: string,
|
||||||
|
data: Partial<InferInsertModel<typeof rankTrackingConfigs>>,
|
||||||
|
) {
|
||||||
|
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<InferInsertModel<typeof rankCheckRuns>>,
|
||||||
|
) {
|
||||||
|
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<InferInsertModel<typeof rankSnapshots>, "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,
|
||||||
|
};
|
||||||
@ -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<string>();
|
||||||
|
const rows: Array<{ id: string; configId: string; keyword: string }> = [];
|
||||||
|
for (const raw of keywords) {
|
||||||
|
if (rows.length >= available) break;
|
||||||
|
const normalized = raw.trim().toLowerCase();
|
||||||
|
if (
|
||||||
|
normalized &&
|
||||||
|
!seen.has(normalized) &&
|
||||||
|
!existingKeywords.has(normalized)
|
||||||
|
) {
|
||||||
|
seen.add(normalized);
|
||||||
|
rows.push({ id: crypto.randomUUID(), configId, keyword: normalized });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
await RankTrackingRepository.addKeywordsToConfig(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { added: rows.length, addedIds: rows.map((r) => r.id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeKeywords(
|
||||||
|
configId: string,
|
||||||
|
projectId: string,
|
||||||
|
keywordIds: string[],
|
||||||
|
) {
|
||||||
|
await getValidatedConfig(configId, projectId);
|
||||||
|
await RankTrackingRepository.removeKeywordsFromConfig(keywordIds, configId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Trigger a manual check
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function triggerCheck(input: {
|
||||||
|
configId: string;
|
||||||
|
projectId: string;
|
||||||
|
billingCustomer: BillingCustomerContext;
|
||||||
|
keywordIds?: string[];
|
||||||
|
}): Promise<RankCheckTriggerResult> {
|
||||||
|
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<ReturnType<typeof RankTrackingRepository.getLatestRunForConfig>>
|
||||||
|
>;
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
272
src/server/features/rank-tracking/services/rankCheckRunGuards.ts
Normal file
272
src/server/features/rank-tracking/services/rankCheckRunGuards.ts
Normal file
@ -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<ReturnType<typeof RankTrackingRepository.getRunById>>;
|
||||||
|
type RunLockRow = Awaited<ReturnType<typeof RankTrackingRepository.getRunLock>>;
|
||||||
|
|
||||||
|
// 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<RankCheckWorkflowStatus["status"]>([
|
||||||
|
"queued",
|
||||||
|
"running",
|
||||||
|
"waiting",
|
||||||
|
"waitingForPause",
|
||||||
|
"paused",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const RANK_CHECK_STARTUP_GRACE_MS = 60 * 1000;
|
||||||
|
|
||||||
|
async function getRankCheckWorkflowStatus(
|
||||||
|
runId: string,
|
||||||
|
): Promise<RankCheckWorkflowStatus | null> {
|
||||||
|
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<RankCheckTriggerResult> {
|
||||||
|
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<RunLockRow>) {
|
||||||
|
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<RunRow>) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@ -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<typeof RankTrackingRepository.getSnapshotsForRun>
|
||||||
|
>[0];
|
||||||
|
|
||||||
|
const PERIOD_DAYS: Record<Exclude<ComparePeriod, "previous">, 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<string, number | null>();
|
||||||
|
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<string, RankTrackingRow>(
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable max-lines */
|
||||||
import {
|
import {
|
||||||
DataforseoLabsApi,
|
DataforseoLabsApi,
|
||||||
DataforseoLabsGoogleRelatedKeywordsLiveRequestInfo,
|
DataforseoLabsGoogleRelatedKeywordsLiveRequestInfo,
|
||||||
@ -7,6 +8,7 @@ import {
|
|||||||
DataforseoLabsGoogleRankedKeywordsLiveRequestInfo,
|
DataforseoLabsGoogleRankedKeywordsLiveRequestInfo,
|
||||||
} from "dataforseo-client";
|
} from "dataforseo-client";
|
||||||
import { env } from "cloudflare:workers";
|
import { env } from "cloudflare:workers";
|
||||||
|
import { z } from "zod";
|
||||||
import type { DataforseoApiResponse } from "@/server/lib/dataforseoCost";
|
import type { DataforseoApiResponse } from "@/server/lib/dataforseoCost";
|
||||||
import { AppError } from "@/server/lib/errors";
|
import { AppError } from "@/server/lib/errors";
|
||||||
import {
|
import {
|
||||||
@ -380,3 +382,99 @@ export async function fetchLiveSerpItemsRaw(
|
|||||||
billing: buildTaskBilling(task),
|
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<DataforseoApiResponse<RankCheckResult>> {
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
fetchDomainRankOverviewRaw,
|
fetchDomainRankOverviewRaw,
|
||||||
fetchRankedKeywordsRaw,
|
fetchRankedKeywordsRaw,
|
||||||
fetchLiveSerpItemsRaw,
|
fetchLiveSerpItemsRaw,
|
||||||
|
fetchRankCheckSerpRaw,
|
||||||
type LabsKeywordDataItem,
|
type LabsKeywordDataItem,
|
||||||
type SerpLiveItem,
|
type SerpLiveItem,
|
||||||
} from "@/server/lib/dataforseo";
|
} from "@/server/lib/dataforseo";
|
||||||
@ -43,7 +44,8 @@ type CreditFeature =
|
|||||||
| "keyword_research"
|
| "keyword_research"
|
||||||
| "domain_overview"
|
| "domain_overview"
|
||||||
| "backlinks"
|
| "backlinks"
|
||||||
| "site_audit";
|
| "site_audit"
|
||||||
|
| "rank_tracking";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps a DataForSEO API response path (e.g. ["v3", "dataforseo_labs", "google", "related_keywords", "live"])
|
* 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: {
|
lighthouse: {
|
||||||
live(input: { url: string; strategy: LighthouseStrategy }) {
|
live(input: { url: string; strategy: LighthouseStrategy }) {
|
||||||
@ -212,6 +228,7 @@ export function createDataforseoClient(customer: BillingCustomerContext) {
|
|||||||
async function meterDataforseoCall<T>(
|
async function meterDataforseoCall<T>(
|
||||||
customer: BillingCustomerContext,
|
customer: BillingCustomerContext,
|
||||||
execute: () => Promise<DataforseoApiResponse<T>>,
|
execute: () => Promise<DataforseoApiResponse<T>>,
|
||||||
|
creditFeature?: CreditFeature,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const isHostedMode = await isHostedServerAuthMode();
|
const isHostedMode = await isHostedServerAuthMode();
|
||||||
|
|
||||||
@ -233,6 +250,7 @@ async function meterDataforseoCall<T>(
|
|||||||
customerId: billingCustomer.id,
|
customerId: billingCustomer.id,
|
||||||
billing: result.billing,
|
billing: result.billing,
|
||||||
monthlyRemaining,
|
monthlyRemaining,
|
||||||
|
creditFeature,
|
||||||
});
|
});
|
||||||
|
|
||||||
return result.data;
|
return result.data;
|
||||||
@ -265,6 +283,7 @@ async function trackDataforseoCost(args: {
|
|||||||
customerId: string;
|
customerId: string;
|
||||||
billing: DataforseoApiCallCost;
|
billing: DataforseoApiCallCost;
|
||||||
monthlyRemaining: number;
|
monthlyRemaining: number;
|
||||||
|
creditFeature?: CreditFeature;
|
||||||
}) {
|
}) {
|
||||||
const totalCostUsd = roundUsdForBilling(
|
const totalCostUsd = roundUsdForBilling(
|
||||||
args.billing.costUsd * SEO_DATA_COST_MARKUP,
|
args.billing.costUsd * SEO_DATA_COST_MARKUP,
|
||||||
@ -316,7 +335,9 @@ async function trackDataforseoCost(args: {
|
|||||||
organizationId: args.customer.organizationId,
|
organizationId: args.customer.organizationId,
|
||||||
properties: {
|
properties: {
|
||||||
project_id: args.customer.projectId,
|
project_id: args.customer.projectId,
|
||||||
credit_feature: mapDataforseoPathToCreditFeature(args.billing.path),
|
credit_feature:
|
||||||
|
args.creditFeature ??
|
||||||
|
mapDataforseoPathToCreditFeature(args.billing.path),
|
||||||
monthly_credits: monthlyDeduct,
|
monthly_credits: monthlyDeduct,
|
||||||
topup_credits: topupDeduct,
|
topup_credits: topupDeduct,
|
||||||
total_credits: totalCostCredits,
|
total_credits: totalCostCredits,
|
||||||
|
|||||||
307
src/server/workflows/RankCheckWorkflow.ts
Normal file
307
src/server/workflows/RankCheckWorkflow.ts
Normal file
@ -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<RankCheckParams>, 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
113
src/server/workflows/rankCheckPaths.ts
Normal file
113
src/server/workflows/rankCheckPaths.ts
Normal file
@ -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<typeof createDataforseoClient>;
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@ -18,11 +18,7 @@ export const startAudit = createServerFn({ method: "POST" })
|
|||||||
.handler(async ({ data, context }) => {
|
.handler(async ({ data, context }) => {
|
||||||
const result = await AuditService.startAudit({
|
const result = await AuditService.startAudit({
|
||||||
actorUserId: context.userId,
|
actorUserId: context.userId,
|
||||||
billingCustomer: {
|
billingCustomer: context,
|
||||||
organizationId: context.organizationId,
|
|
||||||
userEmail: context.userEmail,
|
|
||||||
userId: context.userId,
|
|
||||||
},
|
|
||||||
projectId: context.projectId,
|
projectId: context.projectId,
|
||||||
startUrl: data.startUrl,
|
startUrl: data.startUrl,
|
||||||
maxPages: data.maxPages,
|
maxPages: data.maxPages,
|
||||||
|
|||||||
154
src/serverFunctions/rank-tracking.ts
Normal file
154
src/serverFunctions/rank-tracking.ts
Normal file
@ -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 };
|
||||||
|
});
|
||||||
97
src/shared/rank-tracking.ts
Normal file
97
src/shared/rank-tracking.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
120
src/types/schemas/rank-tracking.ts
Normal file
120
src/types/schemas/rank-tracking.ts
Normal file
@ -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<typeof rankTrackingConfigs>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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<typeof comparePeriodSchema>;
|
||||||
|
|
||||||
|
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),
|
||||||
|
});
|
||||||
19
worker-configuration.d.ts
vendored
19
worker-configuration.d.ts
vendored
@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable */
|
/* 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
|
// Runtime types generated with workerd@1.20260219.0 2025-09-02 nodejs_compat
|
||||||
declare namespace Cloudflare {
|
declare namespace Cloudflare {
|
||||||
interface GlobalProps {
|
interface GlobalProps {
|
||||||
@ -9,16 +9,19 @@ declare namespace Cloudflare {
|
|||||||
KV: KVNamespace;
|
KV: KVNamespace;
|
||||||
R2: R2Bucket;
|
R2: R2Bucket;
|
||||||
DB: D1Database;
|
DB: D1Database;
|
||||||
VITE_APP_ID: string;
|
|
||||||
VITE_GATEWAY_URL: string;
|
|
||||||
GATEWAY_URL: string;
|
|
||||||
GATEWAY_APP_API_TOKEN: string;
|
|
||||||
DATAFORSEO_API_KEY: string;
|
DATAFORSEO_API_KEY: string;
|
||||||
PORT: string;
|
PORT: string;
|
||||||
|
AUTH_MODE: string;
|
||||||
BETTER_AUTH_SECRET: string;
|
BETTER_AUTH_SECRET: string;
|
||||||
JWT_PRIVATE_KEY: string;
|
BETTER_AUTH_URL: string;
|
||||||
JWT_PUBLIC_KEY: 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<Parameters<import("./src/server").SiteAuditWorkflow['run']>[0]['payload']>;
|
SITE_AUDIT_WORKFLOW: Workflow<Parameters<import("./src/server").SiteAuditWorkflow['run']>[0]['payload']>;
|
||||||
|
RANK_CHECK_WORKFLOW: Workflow<Parameters<import("./src/server").RankCheckWorkflow['run']>[0]['payload']>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
interface Env extends Cloudflare.Env {}
|
interface Env extends Cloudflare.Env {}
|
||||||
@ -26,7 +29,7 @@ type StringifyValues<EnvType extends Record<string, unknown>> = {
|
|||||||
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
|
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
|
||||||
};
|
};
|
||||||
declare namespace NodeJS {
|
declare namespace NodeJS {
|
||||||
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "VITE_APP_ID" | "VITE_GATEWAY_URL" | "GATEWAY_URL" | "GATEWAY_APP_API_TOKEN" | "DATAFORSEO_API_KEY" | "PORT" | "BETTER_AUTH_SECRET" | "JWT_PRIVATE_KEY" | "JWT_PUBLIC_KEY">> {}
|
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "DATAFORSEO_API_KEY" | "PORT" | "AUTH_MODE" | "BETTER_AUTH_SECRET" | "BETTER_AUTH_URL" | "AUTUMN_SECRET_KEY" | "LOOPS_API_KEY" | "LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID" | "LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID" | "POSTHOG_HOST" | "POSTHOG_PUBLIC_KEY">> {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Begin runtime types
|
// Begin runtime types
|
||||||
|
|||||||
@ -23,7 +23,15 @@
|
|||||||
"binding": "SITE_AUDIT_WORKFLOW",
|
"binding": "SITE_AUDIT_WORKFLOW",
|
||||||
"class_name": "SiteAuditWorkflow",
|
"class_name": "SiteAuditWorkflow",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "rank-check-workflow",
|
||||||
|
"binding": "RANK_CHECK_WORKFLOW",
|
||||||
|
"class_name": "RankCheckWorkflow",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
|
"triggers": {
|
||||||
|
"crons": ["*/15 * * * *"],
|
||||||
|
},
|
||||||
"kv_namespaces": [
|
"kv_namespaces": [
|
||||||
{
|
{
|
||||||
"binding": "KV",
|
"binding": "KV",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user