feat: Rank Tracking MVP (#91)
This commit is contained in:
parent
fb1291537c
commit
53a83996ef
@ -11,7 +11,8 @@
|
||||
".output",
|
||||
"web",
|
||||
"src/routeTree.gen.ts",
|
||||
"worker-configuration.d.ts"
|
||||
"worker-configuration.d.ts",
|
||||
"scripts"
|
||||
],
|
||||
"rules": {
|
||||
"react/react-in-jsx-scope": "off",
|
||||
@ -36,7 +37,7 @@
|
||||
"eslint/complexity": ["error", { "max": 40 }],
|
||||
"eslint/max-lines": [
|
||||
"error",
|
||||
{ "max": 350, "skipBlankLines": true, "skipComments": true }
|
||||
{ "max": 400, "skipBlankLines": true, "skipComments": true }
|
||||
],
|
||||
"eslint/max-lines-per-function": [
|
||||
"error",
|
||||
|
||||
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
|
||||
- Find topics worth targeting, estimate demand, and prioritize what to write next.
|
||||
- Rank tracking
|
||||
- Monitor keyword positions across desktop and mobile over time, with SERP feature detection.
|
||||
- Domain insights
|
||||
- Understand where your domain is gaining or losing visibility so you can focus on the pages that move revenue.
|
||||
- Backlinks
|
||||
@ -41,8 +43,9 @@ OpenSEO is an SEO tool for _the people_. If tools like Semrush or Ahrefs are too
|
||||
|
||||
Top priorities:
|
||||
|
||||
- Rank tracking
|
||||
- AI SEO like LLM Citations
|
||||
- AI SEO, GEO, LLM Visibility
|
||||
- MCP for Claude
|
||||
- Making the best agentic workflows for SEO
|
||||
|
||||
If something important is missing, please join the [Discord](https://discord.gg/c9uGs3cFXr) or email me at ben@everyapp.dev and request it.
|
||||
|
||||
@ -241,15 +244,25 @@ That means you can try OpenSEO for free with the starter credit, then decide if/
|
||||
|
||||
### Pricing sources
|
||||
|
||||
- DataForSEO SERP API pricing: https://dataforseo.com/apis/serp-api/pricing
|
||||
- DataForSEO Labs pricing: https://dataforseo.com/pricing/dataforseo-labs/dataforseo-google-api
|
||||
- DataForSEO Backlinks pricing: https://dataforseo.com/pricing/backlinks/backlinks
|
||||
- DataForSEO Lighthouse API docs: https://docs.dataforseo.com/v3/on_page/lighthouse/overview/
|
||||
|
||||
### 1) Site audit
|
||||
### 1) Rank tracking
|
||||
|
||||
- No paid API calls in the current implementation.
|
||||
- Uses the SERP Task API with `depth: 100` + `stop_crawl_on_match` (stops early when your domain is found).
|
||||
- Cost per keyword per device: `$0.0006` (ranks top 10) to `$0.00465` (not ranking).
|
||||
- Typical cost per keyword (both devices): `~$0.002` assuming most keywords rank in the top 20.
|
||||
- Planning examples:
|
||||
- 100 keywords, both devices, weekly: `~$0.80/month`
|
||||
- 500 keywords, both devices, weekly: `~$4.00/month`
|
||||
|
||||
### 2) Keyword research (`related` mode)
|
||||
### 2) Site audit
|
||||
|
||||
- $0.01 per 20 pages audited with Lighthouse
|
||||
|
||||
### 3) Keyword research (`related` mode)
|
||||
|
||||
- Current billed cost pattern (from account usage logs):
|
||||
- `0.02 + (0.0001 x returned_keywords)` USD
|
||||
@ -259,13 +272,13 @@ That means you can try OpenSEO for free with the starter credit, then decide if/
|
||||
- 300 results = `$0.05`
|
||||
- 500 results = `$0.07`
|
||||
|
||||
### 3) Domain overview
|
||||
### 4) Domain overview
|
||||
|
||||
- Standard domain overview request (with top 200 ranked keywords): `$0.0401` per domain.
|
||||
- General formula if needed:
|
||||
- `0.0201 + (0.0001 x ranked_keywords_returned)` USD
|
||||
|
||||
### 4) Backlinks search
|
||||
### 5) Backlinks search
|
||||
|
||||
- Backlinks search costs about `$0.06` for a domain or `$0.04` for a page.
|
||||
- Opening extra tabs like `Referring Domains` or `Top Pages` adds about `+$0.02` each.
|
||||
|
||||
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,
|
||||
"tag": "0006_magical_alex_wilder",
|
||||
"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 TopPagesTableSort,
|
||||
} from "./backlinksTableSorting";
|
||||
|
||||
export function ReferringDomainsTableHeader({
|
||||
sort,
|
||||
onSortChange,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { BacklinksOverviewData } from "./backlinksPageTypes";
|
||||
|
||||
export type SortDirection = "asc" | "desc";
|
||||
|
||||
export type ReferringDomainsTableSortField =
|
||||
| "domain"
|
||||
| "backlinks"
|
||||
@ -50,6 +51,7 @@ export function getNextSort<TField extends string>(
|
||||
direction: current.direction === "asc" ? "desc" : "asc",
|
||||
};
|
||||
}
|
||||
|
||||
export function sortReferringDomainRows(
|
||||
rows: BacklinksOverviewData["referringDomains"],
|
||||
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,
|
||||
Link2,
|
||||
Search,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import { linkOptions } from "@tanstack/react-router";
|
||||
|
||||
@ -21,6 +22,12 @@ const projectNavItems = [
|
||||
icon: Bookmark,
|
||||
matchSegment: "/saved",
|
||||
},
|
||||
{
|
||||
to: "/p/$projectId/rank-tracking" as const,
|
||||
label: "Rank Tracking",
|
||||
icon: TrendingUp,
|
||||
matchSegment: "/rank-tracking",
|
||||
},
|
||||
{
|
||||
to: "/p/$projectId/domain" as const,
|
||||
label: "Domain Overview",
|
||||
|
||||
@ -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
|
||||
// ============================================================================
|
||||
|
||||
@ -28,6 +28,7 @@ import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/
|
||||
import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route'
|
||||
import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index'
|
||||
import { Route as ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved'
|
||||
import { Route as ProjectPProjectIdRankTrackingRouteImport } from './routes/_project/p/$projectId/rank-tracking'
|
||||
import { Route as ProjectPProjectIdKeywordsRouteImport } from './routes/_project/p/$projectId/keywords'
|
||||
import { Route as ProjectPProjectIdDomainRouteImport } from './routes/_project/p/$projectId/domain'
|
||||
import { Route as ProjectPProjectIdBacklinksRouteImport } from './routes/_project/p/$projectId/backlinks'
|
||||
@ -127,6 +128,12 @@ const ProjectPProjectIdSavedRoute = ProjectPProjectIdSavedRouteImport.update({
|
||||
path: '/saved',
|
||||
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
||||
} as any)
|
||||
const ProjectPProjectIdRankTrackingRoute =
|
||||
ProjectPProjectIdRankTrackingRouteImport.update({
|
||||
id: '/rank-tracking',
|
||||
path: '/rank-tracking',
|
||||
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
||||
} as any)
|
||||
const ProjectPProjectIdKeywordsRoute =
|
||||
ProjectPProjectIdKeywordsRouteImport.update({
|
||||
id: '/keywords',
|
||||
@ -186,6 +193,7 @@ export interface FileRoutesByFullPath {
|
||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
||||
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
||||
'/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRoute
|
||||
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
||||
'/p/$projectId/': typeof ProjectPProjectIdIndexRoute
|
||||
'/p/$projectId/audit/': typeof ProjectPProjectIdAuditIndexRoute
|
||||
@ -208,6 +216,7 @@ export interface FileRoutesByTo {
|
||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
||||
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
||||
'/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRoute
|
||||
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
||||
'/p/$projectId': typeof ProjectPProjectIdIndexRoute
|
||||
'/p/$projectId/audit': typeof ProjectPProjectIdAuditIndexRoute
|
||||
@ -237,6 +246,7 @@ export interface FileRoutesById {
|
||||
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||
'/_project/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
||||
'/_project/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
||||
'/_project/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRoute
|
||||
'/_project/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
||||
'/_project/p/$projectId/': typeof ProjectPProjectIdIndexRoute
|
||||
'/_project/p/$projectId/audit/': typeof ProjectPProjectIdAuditIndexRoute
|
||||
@ -263,6 +273,7 @@ export interface FileRouteTypes {
|
||||
| '/p/$projectId/backlinks'
|
||||
| '/p/$projectId/domain'
|
||||
| '/p/$projectId/keywords'
|
||||
| '/p/$projectId/rank-tracking'
|
||||
| '/p/$projectId/saved'
|
||||
| '/p/$projectId/'
|
||||
| '/p/$projectId/audit/'
|
||||
@ -285,6 +296,7 @@ export interface FileRouteTypes {
|
||||
| '/p/$projectId/backlinks'
|
||||
| '/p/$projectId/domain'
|
||||
| '/p/$projectId/keywords'
|
||||
| '/p/$projectId/rank-tracking'
|
||||
| '/p/$projectId/saved'
|
||||
| '/p/$projectId'
|
||||
| '/p/$projectId/audit'
|
||||
@ -313,6 +325,7 @@ export interface FileRouteTypes {
|
||||
| '/_project/p/$projectId/backlinks'
|
||||
| '/_project/p/$projectId/domain'
|
||||
| '/_project/p/$projectId/keywords'
|
||||
| '/_project/p/$projectId/rank-tracking'
|
||||
| '/_project/p/$projectId/saved'
|
||||
| '/_project/p/$projectId/'
|
||||
| '/_project/p/$projectId/audit/'
|
||||
@ -466,6 +479,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ProjectPProjectIdSavedRouteImport
|
||||
parentRoute: typeof ProjectPProjectIdRouteRoute
|
||||
}
|
||||
'/_project/p/$projectId/rank-tracking': {
|
||||
id: '/_project/p/$projectId/rank-tracking'
|
||||
path: '/rank-tracking'
|
||||
fullPath: '/p/$projectId/rank-tracking'
|
||||
preLoaderRoute: typeof ProjectPProjectIdRankTrackingRouteImport
|
||||
parentRoute: typeof ProjectPProjectIdRouteRoute
|
||||
}
|
||||
'/_project/p/$projectId/keywords': {
|
||||
id: '/_project/p/$projectId/keywords'
|
||||
path: '/keywords'
|
||||
@ -559,6 +579,7 @@ interface ProjectPProjectIdRouteRouteChildren {
|
||||
ProjectPProjectIdBacklinksRoute: typeof ProjectPProjectIdBacklinksRoute
|
||||
ProjectPProjectIdDomainRoute: typeof ProjectPProjectIdDomainRoute
|
||||
ProjectPProjectIdKeywordsRoute: typeof ProjectPProjectIdKeywordsRoute
|
||||
ProjectPProjectIdRankTrackingRoute: typeof ProjectPProjectIdRankTrackingRoute
|
||||
ProjectPProjectIdSavedRoute: typeof ProjectPProjectIdSavedRoute
|
||||
ProjectPProjectIdIndexRoute: typeof ProjectPProjectIdIndexRoute
|
||||
}
|
||||
@ -570,6 +591,7 @@ const ProjectPProjectIdRouteRouteChildren: ProjectPProjectIdRouteRouteChildren =
|
||||
ProjectPProjectIdBacklinksRoute: ProjectPProjectIdBacklinksRoute,
|
||||
ProjectPProjectIdDomainRoute: ProjectPProjectIdDomainRoute,
|
||||
ProjectPProjectIdKeywordsRoute: ProjectPProjectIdKeywordsRoute,
|
||||
ProjectPProjectIdRankTrackingRoute: ProjectPProjectIdRankTrackingRoute,
|
||||
ProjectPProjectIdSavedRoute: ProjectPProjectIdSavedRoute,
|
||||
ProjectPProjectIdIndexRoute: ProjectPProjectIdIndexRoute,
|
||||
}
|
||||
|
||||
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,
|
||||
removeSavedKeyword,
|
||||
} from "@/serverFunctions/keywords";
|
||||
import { Trash2, Download, Search, Loader2, AlertCircle } from "lucide-react";
|
||||
import {
|
||||
Download,
|
||||
Search,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Trash2,
|
||||
Copy,
|
||||
} from "lucide-react";
|
||||
import { buildCsv, downloadCsv } from "@/client/lib/csv";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
@ -15,17 +22,30 @@ export const Route = createFileRoute("/_project/p/$projectId/saved")({
|
||||
component: SavedKeywordsPage,
|
||||
});
|
||||
|
||||
type SavedKeyword = {
|
||||
id: string;
|
||||
keyword: string;
|
||||
searchVolume: number | null;
|
||||
cpc: number | null;
|
||||
competition: number | null;
|
||||
keywordDifficulty: number | null;
|
||||
intent: string | null;
|
||||
fetchedAt: string | null;
|
||||
};
|
||||
|
||||
function SavedKeywordsPage() {
|
||||
const { projectId } = Route.useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const [removeError, setRemoveError] = useState<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({
|
||||
queryKey: ["savedKeywords", projectId],
|
||||
queryFn: () => getSavedKeywords({ data: { projectId } }),
|
||||
});
|
||||
const savedKeywords = savedKeywordsData?.rows ?? [];
|
||||
const savedKeywords: SavedKeyword[] = savedKeywordsData?.rows ?? [];
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (savedKeywordId: string) =>
|
||||
@ -42,16 +62,54 @@ function SavedKeywordsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleRemoveKeyword = (savedKeywordId: string) => {
|
||||
const handleDeleteSelected = async () => {
|
||||
setDeleting(true);
|
||||
setRemoveError(null);
|
||||
setRemovingId(savedKeywordId);
|
||||
removeMutation.mutate(savedKeywordId, {
|
||||
onSettled: () => {
|
||||
setRemovingId((current) =>
|
||||
current === savedKeywordId ? null : current,
|
||||
);
|
||||
},
|
||||
const ids = [...selected];
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await removeMutation.mutateAsync(id);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
setDeleting(false);
|
||||
setSelected(new Set());
|
||||
setShowConfirm(false);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["savedKeywords", projectId],
|
||||
});
|
||||
captureClientEvent("saved_keywords:bulk_remove");
|
||||
toast.success(
|
||||
`${ids.length} keyword${ids.length !== 1 ? "s" : ""} removed`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleCopySelected = () => {
|
||||
const keywords = savedKeywords
|
||||
.filter((kw) => selected.has(kw.id))
|
||||
.map((kw) => kw.keyword);
|
||||
void navigator.clipboard.writeText(keywords.join("\n"));
|
||||
toast.success(
|
||||
`${keywords.length} keyword${keywords.length !== 1 ? "s" : ""} copied`,
|
||||
);
|
||||
};
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
if (selected.size === savedKeywords.length) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(savedKeywords.map((kw) => kw.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const exportCsv = () => {
|
||||
@ -59,7 +117,6 @@ function SavedKeywordsPage() {
|
||||
toast.error("No keywords to export");
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = [
|
||||
"Keyword",
|
||||
"Volume",
|
||||
@ -86,42 +143,6 @@ function SavedKeywordsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<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 (
|
||||
<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">
|
||||
@ -133,7 +154,7 @@ function SavedKeywordsContent({
|
||||
</p>
|
||||
</div>
|
||||
{savedKeywords.length > 0 && (
|
||||
<button className="btn btn-sm" onClick={onExportCsv}>
|
||||
<button className="btn btn-sm" onClick={exportCsv}>
|
||||
<Download className="size-4" /> Export CSV
|
||||
</button>
|
||||
)}
|
||||
@ -178,46 +199,57 @@ function SavedKeywordsContent({
|
||||
<span>{removeError}</span>
|
||||
</div>
|
||||
) : 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">
|
||||
{savedKeywords.length} saved keyword
|
||||
{savedKeywords.length !== 1 ? "s" : ""}
|
||||
</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">
|
||||
<table className="table table-zebra table-sm">
|
||||
<thead>
|
||||
<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>Volume</th>
|
||||
<th>CPC</th>
|
||||
@ -225,17 +257,28 @@ function SavedKeywordsTable({
|
||||
<th>Difficulty</th>
|
||||
<th>Intent</th>
|
||||
<th>Last Fetched</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((kw) => (
|
||||
{savedKeywords.map((kw) => (
|
||||
<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>{formatNumber(kw.searchVolume)}</td>
|
||||
<td>{kw.cpc == null ? "-" : `$${kw.cpc.toFixed(2)}`}</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>
|
||||
<DifficultyBadge value={kw.keywordDifficulty} />
|
||||
@ -250,25 +293,48 @@ function SavedKeywordsTable({
|
||||
? new Date(kw.fetchedAt).toLocaleDateString()
|
||||
: "-"}
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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,
|
||||
defaultStreamHandler,
|
||||
} from "@tanstack/react-start/server";
|
||||
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
|
||||
import { beginRankCheckRun } from "@/server/features/rank-tracking/services/rankCheckRunGuards";
|
||||
import { customerHasManagedServiceAccess } from "@/server/billing/subscription";
|
||||
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
||||
import { computeNextCheckAt } from "@/shared/rank-tracking";
|
||||
|
||||
const fetch = createStartHandler(defaultStreamHandler);
|
||||
|
||||
// Export Workflow classes as named exports
|
||||
export { SiteAuditWorkflow } from "./server/workflows/SiteAuditWorkflow";
|
||||
export { RankCheckWorkflow } from "./server/workflows/RankCheckWorkflow";
|
||||
|
||||
export default {
|
||||
fetch,
|
||||
async scheduled(
|
||||
_controller: ScheduledController,
|
||||
env: Env,
|
||||
_ctx: ExecutionContext,
|
||||
) {
|
||||
const nowIso = new Date().toISOString();
|
||||
const dueConfigs =
|
||||
await RankTrackingRepository.getDueConfigsWithOrganization(nowIso);
|
||||
|
||||
const isHosted = await isHostedServerAuthMode();
|
||||
|
||||
for (const config of dueConfigs) {
|
||||
try {
|
||||
// Skip configs whose org no longer has paid access
|
||||
if (
|
||||
isHosted &&
|
||||
!(await customerHasManagedServiceAccess(config.organizationId))
|
||||
) {
|
||||
console.log(
|
||||
`[cron] Skipping config ${config.id} (${config.domain}) — org ${config.organizationId} no longer has access`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip configs with no keywords before advancing the schedule
|
||||
const kwCount = await RankTrackingRepository.getKeywordCountForConfig(
|
||||
config.id,
|
||||
);
|
||||
if (kwCount === 0) {
|
||||
console.log(
|
||||
`[cron] Skipping config ${config.id} (${config.domain}) — no keywords`,
|
||||
);
|
||||
// Still advance schedule so this config doesn't stay due forever
|
||||
const skipInterval =
|
||||
config.scheduleInterval === "daily" ||
|
||||
config.scheduleInterval === "weekly"
|
||||
? config.scheduleInterval
|
||||
: null;
|
||||
if (skipInterval) {
|
||||
await RankTrackingRepository.updateConfig(
|
||||
config.id,
|
||||
config.projectId,
|
||||
{
|
||||
nextCheckAt: computeNextCheckAt(
|
||||
skipInterval,
|
||||
config.nextCheckAt,
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Advance nextCheckAt immediately to prevent retry storms if the run fails
|
||||
const interval =
|
||||
config.scheduleInterval === "daily" ||
|
||||
config.scheduleInterval === "weekly"
|
||||
? config.scheduleInterval
|
||||
: null;
|
||||
if (interval) {
|
||||
await RankTrackingRepository.updateConfig(
|
||||
config.id,
|
||||
config.projectId,
|
||||
{
|
||||
nextCheckAt: computeNextCheckAt(interval, config.nextCheckAt),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const result = await beginRankCheckRun({
|
||||
workflow: env.RANK_CHECK_WORKFLOW,
|
||||
config,
|
||||
projectId: config.projectId,
|
||||
billingCustomer: {
|
||||
userId: "system",
|
||||
userEmail: "system@openseo.so",
|
||||
organizationId: config.organizationId,
|
||||
projectId: config.projectId,
|
||||
},
|
||||
keywordsTotal: kwCount,
|
||||
trigger: "scheduled",
|
||||
workflowStartErrorMessage: "Failed to start scheduled workflow",
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
console.log(
|
||||
`[cron] Skipping config ${config.id} (${config.domain}) — run already active`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`[cron] Started scheduled rank check ${result.runId} for config ${config.id} (${config.domain})`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[cron] Error processing config ${config.id} (${config.domain}):`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@ -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 {
|
||||
DataforseoLabsApi,
|
||||
DataforseoLabsGoogleRelatedKeywordsLiveRequestInfo,
|
||||
@ -7,6 +8,7 @@ import {
|
||||
DataforseoLabsGoogleRankedKeywordsLiveRequestInfo,
|
||||
} from "dataforseo-client";
|
||||
import { env } from "cloudflare:workers";
|
||||
import { z } from "zod";
|
||||
import type { DataforseoApiResponse } from "@/server/lib/dataforseoCost";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import {
|
||||
@ -380,3 +382,99 @@ export async function fetchLiveSerpItemsRaw(
|
||||
billing: buildTaskBilling(task),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SERP Rank Check API wrapper (Google Organic Live with target matching)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RankCheckResult {
|
||||
keywordId: string;
|
||||
keyword: string;
|
||||
position: number | null;
|
||||
url: string | null;
|
||||
serpFeatures: string[];
|
||||
}
|
||||
|
||||
export async function fetchRankCheckSerpRaw(input: {
|
||||
keyword: string;
|
||||
keywordId: string;
|
||||
locationCode: number;
|
||||
languageCode: string;
|
||||
device: "desktop" | "mobile";
|
||||
targetDomain: string;
|
||||
}): Promise<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,
|
||||
fetchRankedKeywordsRaw,
|
||||
fetchLiveSerpItemsRaw,
|
||||
fetchRankCheckSerpRaw,
|
||||
type LabsKeywordDataItem,
|
||||
type SerpLiveItem,
|
||||
} from "@/server/lib/dataforseo";
|
||||
@ -43,7 +44,8 @@ type CreditFeature =
|
||||
| "keyword_research"
|
||||
| "domain_overview"
|
||||
| "backlinks"
|
||||
| "site_audit";
|
||||
| "site_audit"
|
||||
| "rank_tracking";
|
||||
|
||||
/**
|
||||
* Maps a DataForSEO API response path (e.g. ["v3", "dataforseo_labs", "google", "related_keywords", "live"])
|
||||
@ -198,6 +200,20 @@ export function createDataforseoClient(customer: BillingCustomerContext) {
|
||||
),
|
||||
);
|
||||
},
|
||||
rankCheck(input: {
|
||||
keyword: string;
|
||||
keywordId: string;
|
||||
locationCode: number;
|
||||
languageCode: string;
|
||||
device: "desktop" | "mobile";
|
||||
targetDomain: string;
|
||||
}) {
|
||||
return meterDataforseoCall(
|
||||
customer,
|
||||
() => fetchRankCheckSerpRaw(input),
|
||||
"rank_tracking",
|
||||
);
|
||||
},
|
||||
},
|
||||
lighthouse: {
|
||||
live(input: { url: string; strategy: LighthouseStrategy }) {
|
||||
@ -212,6 +228,7 @@ export function createDataforseoClient(customer: BillingCustomerContext) {
|
||||
async function meterDataforseoCall<T>(
|
||||
customer: BillingCustomerContext,
|
||||
execute: () => Promise<DataforseoApiResponse<T>>,
|
||||
creditFeature?: CreditFeature,
|
||||
): Promise<T> {
|
||||
const isHostedMode = await isHostedServerAuthMode();
|
||||
|
||||
@ -233,6 +250,7 @@ async function meterDataforseoCall<T>(
|
||||
customerId: billingCustomer.id,
|
||||
billing: result.billing,
|
||||
monthlyRemaining,
|
||||
creditFeature,
|
||||
});
|
||||
|
||||
return result.data;
|
||||
@ -265,6 +283,7 @@ async function trackDataforseoCost(args: {
|
||||
customerId: string;
|
||||
billing: DataforseoApiCallCost;
|
||||
monthlyRemaining: number;
|
||||
creditFeature?: CreditFeature;
|
||||
}) {
|
||||
const totalCostUsd = roundUsdForBilling(
|
||||
args.billing.costUsd * SEO_DATA_COST_MARKUP,
|
||||
@ -316,7 +335,9 @@ async function trackDataforseoCost(args: {
|
||||
organizationId: args.customer.organizationId,
|
||||
properties: {
|
||||
project_id: args.customer.projectId,
|
||||
credit_feature: mapDataforseoPathToCreditFeature(args.billing.path),
|
||||
credit_feature:
|
||||
args.creditFeature ??
|
||||
mapDataforseoPathToCreditFeature(args.billing.path),
|
||||
monthly_credits: monthlyDeduct,
|
||||
topup_credits: topupDeduct,
|
||||
total_credits: totalCostCredits,
|
||||
|
||||
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 }) => {
|
||||
const result = await AuditService.startAudit({
|
||||
actorUserId: context.userId,
|
||||
billingCustomer: {
|
||||
organizationId: context.organizationId,
|
||||
userEmail: context.userEmail,
|
||||
userId: context.userId,
|
||||
},
|
||||
billingCustomer: context,
|
||||
projectId: context.projectId,
|
||||
startUrl: data.startUrl,
|
||||
maxPages: data.maxPages,
|
||||
|
||||
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 */
|
||||
// Generated by Wrangler by running `wrangler types` (hash: 0e03c138742d121a834f2931f5295961)
|
||||
// Generated by Wrangler by running `wrangler types` (hash: a66fa3c1bf7b028f26292b2e592c2ff1)
|
||||
// Runtime types generated with workerd@1.20260219.0 2025-09-02 nodejs_compat
|
||||
declare namespace Cloudflare {
|
||||
interface GlobalProps {
|
||||
@ -9,16 +9,19 @@ declare namespace Cloudflare {
|
||||
KV: KVNamespace;
|
||||
R2: R2Bucket;
|
||||
DB: D1Database;
|
||||
VITE_APP_ID: string;
|
||||
VITE_GATEWAY_URL: string;
|
||||
GATEWAY_URL: string;
|
||||
GATEWAY_APP_API_TOKEN: string;
|
||||
DATAFORSEO_API_KEY: string;
|
||||
PORT: string;
|
||||
AUTH_MODE: string;
|
||||
BETTER_AUTH_SECRET: string;
|
||||
JWT_PRIVATE_KEY: string;
|
||||
JWT_PUBLIC_KEY: string;
|
||||
BETTER_AUTH_URL: string;
|
||||
AUTUMN_SECRET_KEY: string;
|
||||
LOOPS_API_KEY: string;
|
||||
LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID: string;
|
||||
LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID: string;
|
||||
POSTHOG_HOST: string;
|
||||
POSTHOG_PUBLIC_KEY: string;
|
||||
SITE_AUDIT_WORKFLOW: Workflow<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 {}
|
||||
@ -26,7 +29,7 @@ type StringifyValues<EnvType extends Record<string, unknown>> = {
|
||||
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
|
||||
};
|
||||
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
|
||||
|
||||
@ -23,7 +23,15 @@
|
||||
"binding": "SITE_AUDIT_WORKFLOW",
|
||||
"class_name": "SiteAuditWorkflow",
|
||||
},
|
||||
{
|
||||
"name": "rank-check-workflow",
|
||||
"binding": "RANK_CHECK_WORKFLOW",
|
||||
"class_name": "RankCheckWorkflow",
|
||||
},
|
||||
],
|
||||
"triggers": {
|
||||
"crons": ["*/15 * * * *"],
|
||||
},
|
||||
"kv_namespaces": [
|
||||
{
|
||||
"binding": "KV",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user