feat: add support for more countries (#25)

* fix: cap per-user audit capacity

Block new audits before a user exceeds the temporary 100k audit footprint cap. Surface a clear delete-old-audits message in the audit launch UI.

* fix: reserve audit capacity with Drizzle

* fix: add Ireland keyword location support

* feat: expand keyword country defaults

* test: add keyword location smoke test

* test: load keyword smoke test env locally

* chore: remove keyword location smoke test

* refactor: simplify keyword location state

* fix: preserve explicit keyword location params
This commit is contained in:
Ben Senescu 2026-03-16 14:20:16 -04:00 committed by GitHub
parent 7b4f85dc10
commit c0d64f366e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 397 additions and 80 deletions

View File

@ -18,6 +18,7 @@ import {
useSettingsForm, useSettingsForm,
type LaunchState, type LaunchState,
} from "@/client/features/audit/launch/types"; } from "@/client/features/audit/launch/types";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
export function useLaunchController({ export function useLaunchController({
projectId, projectId,
@ -116,8 +117,7 @@ export function useLaunchController({
onError: (error) => { onError: (error) => {
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
startError: startError: getStandardErrorMessage(error, "Failed to start audit"),
error instanceof Error ? error.message : "Failed to start audit",
})); }));
}, },
}, },

View File

@ -1,4 +1,5 @@
import { useForm } from "@tanstack/react-form"; import { useForm } from "@tanstack/react-form";
import { useEffect } from "react";
import type { import type {
KeywordMode, KeywordMode,
ResultLimit, ResultLimit,
@ -12,7 +13,7 @@ type UseKeywordControlsFormInput = {
}; };
export function useKeywordControlsForm(input: UseKeywordControlsFormInput) { export function useKeywordControlsForm(input: UseKeywordControlsFormInput) {
return useForm({ const form = useForm({
defaultValues: { defaultValues: {
keyword: input.keywordInput, keyword: input.keywordInput,
locationCode: input.locationCode, locationCode: input.locationCode,
@ -20,4 +21,19 @@ export function useKeywordControlsForm(input: UseKeywordControlsFormInput) {
mode: input.keywordMode, mode: input.keywordMode,
}, },
}); });
useEffect(() => {
form.setFieldValue("keyword", input.keywordInput);
form.setFieldValue("locationCode", input.locationCode);
form.setFieldValue("resultLimit", input.resultLimit);
form.setFieldValue("mode", input.keywordMode);
}, [
form,
input.keywordInput,
input.keywordMode,
input.locationCode,
input.resultLimit,
]);
return form;
} }

View File

@ -0,0 +1,50 @@
import { useEffect, useState } from "react";
import { z } from "zod";
import {
DEFAULT_LOCATION_CODE,
isSupportedLocationCode,
} from "@/client/features/keywords/locations";
const STORAGE_KEY = "keyword-preferred-location";
const locationCodeSchema = z.number().int().positive();
function loadPreferredLocationCode() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = locationCodeSchema.parse(JSON.parse(raw));
return isSupportedLocationCode(parsed) ? parsed : null;
} catch {
return null;
}
}
function savePreferredLocationCode(locationCode: number) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(locationCode));
} catch {
// storage full or unavailable - silently ignore
}
}
export function usePreferredKeywordLocation() {
const [preferredLocationCode, setPreferredLocationCodeState] = useState(
DEFAULT_LOCATION_CODE,
);
useEffect(() => {
const savedLocationCode = loadPreferredLocationCode();
if (savedLocationCode != null) {
setPreferredLocationCodeState(savedLocationCode);
}
}, []);
function setPreferredLocationCode(locationCode: number) {
if (!isSupportedLocationCode(locationCode)) return;
setPreferredLocationCodeState(locationCode);
savePreferredLocationCode(locationCode);
}
return { preferredLocationCode, setPreferredLocationCode };
}

View File

@ -28,7 +28,7 @@ export function normalizeLegacyKeywordSearch(search: KeywordSearchParams): {
const normalized: KeywordSearchParams = { const normalized: KeywordSearchParams = {
...search, ...search,
q: search.q === "" ? undefined : search.q, q: search.q === "" ? undefined : search.q,
loc: search.loc === 2840 ? undefined : search.loc, loc: search.loc,
kLimit: search.kLimit === 150 ? undefined : search.kLimit, kLimit: search.kLimit === 150 ? undefined : search.kLimit,
mode: search.mode === "auto" ? undefined : search.mode, mode: search.mode === "auto" ? undefined : search.mode,
sort: search.sort === "searchVolume" ? undefined : search.sort, sort: search.sort === "searchVolume" ? undefined : search.sort,

View File

@ -0,0 +1,70 @@
export const DEFAULT_LOCATION_CODE = 2840;
export const LOCATION_OPTIONS = [
{ code: 2840, label: "United States", shortLabel: "US", languageCode: "en" },
{ code: 2826, label: "United Kingdom", shortLabel: "UK", languageCode: "en" },
{ code: 2124, label: "Canada", shortLabel: "CA", languageCode: "en" },
{ code: 2036, label: "Australia", shortLabel: "AU", languageCode: "en" },
{ code: 2372, label: "Ireland", shortLabel: "IE", languageCode: "en" },
{ code: 2554, label: "New Zealand", shortLabel: "NZ", languageCode: "en" },
{ code: 2356, label: "India", shortLabel: "IN", languageCode: "en" },
{ code: 2702, label: "Singapore", shortLabel: "SG", languageCode: "en" },
{ code: 2710, label: "South Africa", shortLabel: "ZA", languageCode: "en" },
{ code: 2608, label: "Philippines", shortLabel: "PH", languageCode: "en" },
{ code: 2276, label: "Germany", shortLabel: "DE", languageCode: "de" },
{ code: 2250, label: "France", shortLabel: "FR", languageCode: "fr" },
{ code: 2528, label: "Netherlands", shortLabel: "NL", languageCode: "nl" },
{ code: 2724, label: "Spain", shortLabel: "ES", languageCode: "es" },
{ code: 2380, label: "Italy", shortLabel: "IT", languageCode: "it" },
{ code: 2620, label: "Portugal", shortLabel: "PT", languageCode: "pt" },
{ code: 2040, label: "Austria", shortLabel: "AT", languageCode: "de" },
{ code: 2756, label: "Switzerland", shortLabel: "CH", languageCode: "de" },
{ code: 2752, label: "Sweden", shortLabel: "SE", languageCode: "sv" },
{ code: 2578, label: "Norway", shortLabel: "NO", languageCode: "nb" },
{ code: 2208, label: "Denmark", shortLabel: "DK", languageCode: "da" },
{ code: 2616, label: "Poland", shortLabel: "PL", languageCode: "pl" },
{ code: 2203, label: "Czechia", shortLabel: "CZ", languageCode: "cs" },
{ code: 2642, label: "Romania", shortLabel: "RO", languageCode: "ro" },
{ code: 2792, label: "Turkey", shortLabel: "TR", languageCode: "tr" },
{ code: 2300, label: "Greece", shortLabel: "GR", languageCode: "el" },
{ code: 2348, label: "Hungary", shortLabel: "HU", languageCode: "hu" },
{ code: 2076, label: "Brazil", shortLabel: "BR", languageCode: "pt" },
{ code: 2484, label: "Mexico", shortLabel: "MX", languageCode: "es" },
{ code: 2032, label: "Argentina", shortLabel: "AR", languageCode: "es" },
{ code: 2170, label: "Colombia", shortLabel: "CO", languageCode: "es" },
{ code: 2152, label: "Chile", shortLabel: "CL", languageCode: "es" },
{ code: 2604, label: "Peru", shortLabel: "PE", languageCode: "es" },
{ code: 2392, label: "Japan", shortLabel: "JP", languageCode: "ja" },
{ code: 2410, label: "South Korea", shortLabel: "KR", languageCode: "ko" },
{ code: 2360, label: "Indonesia", shortLabel: "ID", languageCode: "id" },
{ code: 2458, label: "Malaysia", shortLabel: "MY", languageCode: "ms" },
{ code: 2764, label: "Thailand", shortLabel: "TH", languageCode: "th" },
{ code: 2704, label: "Vietnam", shortLabel: "VN", languageCode: "vi" },
{
code: 2784,
label: "United Arab Emirates",
shortLabel: "AE",
languageCode: "en",
},
{ code: 2682, label: "Saudi Arabia", shortLabel: "SA", languageCode: "ar" },
] as const;
const LOCATION_CODES = new Set<number>(
LOCATION_OPTIONS.map((option) => option.code),
);
export const LOCATIONS: Record<number, string> = Object.fromEntries(
LOCATION_OPTIONS.map((option) => [option.code, option.shortLabel]),
);
const LOCATION_LANGUAGE: Record<number, string> = Object.fromEntries(
LOCATION_OPTIONS.map((option) => [option.code, option.languageCode]),
);
export function getLanguageCode(locationCode: number): string {
return LOCATION_LANGUAGE[locationCode] ?? "en";
}
export function isSupportedLocationCode(locationCode: number): boolean {
return LOCATION_CODES.has(locationCode);
}

View File

@ -4,23 +4,13 @@ import {
normalizeKeywordMode, normalizeKeywordMode,
} from "@/client/features/keywords/keywordSearchParams"; } from "@/client/features/keywords/keywordSearchParams";
import { RESULT_LIMITS } from "@/client/features/keywords/keywordResearchTypes"; import { RESULT_LIMITS } from "@/client/features/keywords/keywordResearchTypes";
import { LOCATION_OPTIONS } from "@/client/features/keywords/locations";
import type { KeywordResearchControllerState } from "./types"; import type { KeywordResearchControllerState } from "./types";
type Props = { type Props = {
controller: KeywordResearchControllerState; controller: KeywordResearchControllerState;
}; };
const LOCATION_OPTIONS = [
{ code: 2840, label: "United States" },
{ code: 2826, label: "United Kingdom" },
{ code: 2276, label: "Germany" },
{ code: 2250, label: "France" },
{ code: 2036, label: "Australia" },
{ code: 2124, label: "Canada" },
{ code: 2356, label: "India" },
{ code: 2076, label: "Brazil" },
];
export function KeywordResearchSearchBar({ controller }: Props) { export function KeywordResearchSearchBar({ controller }: Props) {
const { controlsForm, handleSearchSubmit, isLoading, searchInputError } = const { controlsForm, handleSearchSubmit, isLoading, searchInputError } =
controller; controller;

View File

@ -1,5 +1,6 @@
import { type FormEvent } from "react"; import { type FormEvent } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
import { buildCsv, downloadCsv } from "@/client/lib/csv"; import { buildCsv, downloadCsv } from "@/client/lib/csv";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import type { import type {
@ -48,6 +49,7 @@ type SearchActionParams = {
setSearchParams: ( setSearchParams: (
updates: Record<string, string | number | boolean | undefined>, updates: Record<string, string | number | boolean | undefined>,
) => void; ) => void;
setPreferredLocationCode: (locationCode: number) => void;
}; };
type SaveExportActionParams = { type SaveExportActionParams = {
@ -96,6 +98,7 @@ export function useSearchActions(params: SearchActionParams) {
setSerpPage, setSerpPage,
setSearchInputError, setSearchInputError,
setSearchParams, setSearchParams,
setPreferredLocationCode,
} = params; } = params;
const onSearch = ( const onSearch = (
@ -120,9 +123,14 @@ export function useSearchActions(params: SearchActionParams) {
} }
setSearchInputError(null); setSearchInputError(null);
setPreferredLocationCode(activeLocation);
setSearchParams({ setSearchParams({
q: inputKeyword, q: inputKeyword,
loc: activeLocation === 2840 ? undefined : activeLocation, loc:
input.hasExplicitLocationCode ||
activeLocation !== DEFAULT_LOCATION_CODE
? activeLocation
: undefined,
kLimit: activeResultLimit === 150 ? undefined : activeResultLimit, kLimit: activeResultLimit === 150 ? undefined : activeResultLimit,
mode: activeMode === "auto" ? undefined : activeMode, mode: activeMode === "auto" ? undefined : activeMode,
}); });

View File

@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useKeywordControlsForm } from "@/client/features/keywords/hooks/useKeywordControlsForm"; import { useKeywordControlsForm } from "@/client/features/keywords/hooks/useKeywordControlsForm";
import { useKeywordFiltering } from "@/client/features/keywords/hooks/useKeywordFiltering"; import { useKeywordFiltering } from "@/client/features/keywords/hooks/useKeywordFiltering";
import { usePreferredKeywordLocation } from "@/client/features/keywords/hooks/usePreferredKeywordLocation";
import { useLocalKeywordFilters } from "@/client/features/keywords/hooks/useLocalKeywordFilters"; import { useLocalKeywordFilters } from "@/client/features/keywords/hooks/useLocalKeywordFilters";
import { useKeywordResearchData } from "@/client/features/keywords/hooks/useKeywordResearchData"; import { useKeywordResearchData } from "@/client/features/keywords/hooks/useKeywordResearchData";
import { useKeywordSelection } from "@/client/features/keywords/hooks/useKeywordSelection"; import { useKeywordSelection } from "@/client/features/keywords/hooks/useKeywordSelection";
@ -25,6 +26,7 @@ export type KeywordResearchControllerInput = {
projectId: string; projectId: string;
keywordInput: string; keywordInput: string;
locationCode: number; locationCode: number;
hasExplicitLocationCode: boolean;
resultLimit: ResultLimit; resultLimit: ResultLimit;
keywordMode: KeywordMode; keywordMode: KeywordMode;
sortField: SortField; sortField: SortField;
@ -47,6 +49,7 @@ export function useKeywordResearchController(
setSerpPage: state.setSerpPage, setSerpPage: state.setSerpPage,
setSearchInputError: state.setSearchInputError, setSearchInputError: state.setSearchInputError,
setSearchParams: state.setSearchParams, setSearchParams: state.setSearchParams,
setPreferredLocationCode: state.setPreferredLocationCode,
}); });
const { handleSaveKeywords, confirmSave, exportCsv } = const { handleSaveKeywords, confirmSave, exportCsv } =
@ -122,11 +125,14 @@ export function useKeywordResearchController(
} }
function useKeywordControllerState(input: KeywordResearchControllerInput) { function useKeywordControllerState(input: KeywordResearchControllerInput) {
const [showFilters, setShowFilters] = useState(false); const uiState = useKeywordUiState();
const [selectedKeyword, setSelectedKeyword] = const { locationCode, setPreferredLocationCode } =
useState<KeywordResearchRow | null>(null); useResolvedKeywordLocation(input);
const controlsForm = useKeywordControlsForm(input); const controlsForm = useKeywordControlsForm({
...input,
locationCode,
});
const { const {
filtersForm, filtersForm,
values: filterValues, values: filterValues,
@ -144,7 +150,7 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
activeSerpKeyword, activeSerpKeyword,
serpLoading, serpLoading,
serpError, serpError,
} = useKeywordSerpAnalysis(input.locationCode); } = useKeywordSerpAnalysis(locationCode);
const { const {
history, history,
@ -168,9 +174,6 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
beginSearch, beginSearch,
runSearch, runSearch,
} = useKeywordResearchData(addSearch); } = useKeywordResearchData(addSearch);
const [searchInputError, setSearchInputError] = useState<string | null>(null);
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [mobileTab, setMobileTab] = useState<"keywords" | "serp">("keywords");
const setSearchParams = useKeywordSearchParams(); const setSearchParams = useKeywordSearchParams();
const saveMutation = useKeywordSaveMutation(input.projectId); const saveMutation = useKeywordSaveMutation(input.projectId);
@ -185,14 +188,14 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
useKeywordOverviewState({ useKeywordOverviewState({
rows, rows,
searchedKeyword, searchedKeyword,
selectedKeyword, selectedKeyword: uiState.selectedKeyword,
hasSearched, hasSearched,
isLoading, isLoading,
lastSearchError, lastSearchError,
keywordMode: input.keywordMode, keywordMode: input.keywordMode,
}); });
return { return buildKeywordControllerState({
activeFilterCount, activeFilterCount,
activeSerpKeyword, activeSerpKeyword,
beginSearch, beginSearch,
@ -210,7 +213,7 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
lastSearchKeyword, lastSearchKeyword,
lastSearchLocationCode, lastSearchLocationCode,
lastUsedFallback, lastUsedFallback,
mobileTab, mobileTab: uiState.mobileTab,
overviewKeyword, overviewKeyword,
removeHistoryItem, removeHistoryItem,
researchError, researchError,
@ -218,11 +221,12 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
resetFilters, resetFilters,
rows, rows,
searchedKeyword, searchedKeyword,
searchInputError, searchInputError: uiState.searchInputError,
selectedKeyword, selectedKeyword: uiState.selectedKeyword,
selectedRows, selectedRows,
saveMutation, saveMutation,
setSelectedKeyword, setPreferredLocationCode,
setSelectedKeyword: uiState.setSelectedKeyword,
setSearchParams, setSearchParams,
setSerpKeyword, setSerpKeyword,
serpError, serpError,
@ -230,17 +234,50 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
serpPage, serpPage,
serpQuery, serpQuery,
serpResults, serpResults,
setMobileTab, setMobileTab: uiState.setMobileTab,
setSearchInputError, setSearchInputError: uiState.setSearchInputError,
setSerpPage, setSerpPage,
setShowFilters, setShowFilters: uiState.setShowFilters,
setShowSaveDialog, setShowSaveDialog: uiState.setShowSaveDialog,
showApproximateMatchNotice, showApproximateMatchNotice,
showFilters, showFilters: uiState.showFilters,
showSaveDialog, showSaveDialog: uiState.showSaveDialog,
toggleAllRows, toggleAllRows,
toggleRowSelection, toggleRowSelection,
SERP_PAGE_SIZE, SERP_PAGE_SIZE,
});
}
function useResolvedKeywordLocation(input: KeywordResearchControllerInput) {
const { preferredLocationCode, setPreferredLocationCode } =
usePreferredKeywordLocation();
const locationCode =
!input.hasExplicitLocationCode && input.keywordInput === ""
? preferredLocationCode
: input.locationCode;
return { locationCode, setPreferredLocationCode };
}
function useKeywordUiState() {
const [showFilters, setShowFilters] = useState(false);
const [selectedKeyword, setSelectedKeyword] =
useState<KeywordResearchRow | null>(null);
const [searchInputError, setSearchInputError] = useState<string | null>(null);
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [mobileTab, setMobileTab] = useState<"keywords" | "serp">("keywords");
return {
mobileTab,
searchInputError,
selectedKeyword,
setMobileTab,
setSearchInputError,
setSelectedKeyword,
setShowFilters,
setShowSaveDialog,
showFilters,
showSaveDialog,
}; };
} }
@ -279,3 +316,9 @@ function useKeywordSaveMutation(projectId: string) {
function buildControllerOutput<T extends Record<string, unknown>>(state: T): T { function buildControllerOutput<T extends Record<string, unknown>>(state: T): T {
return state; return state;
} }
function buildKeywordControllerState<T extends Record<string, unknown>>(
state: T,
): T {
return state;
}

View File

@ -1,28 +1,4 @@
export const LOCATIONS: Record<number, string> = { export { LOCATIONS, getLanguageCode } from "./locations";
2840: "US",
2826: "UK",
2276: "DE",
2250: "FR",
2036: "AU",
2124: "CA",
2356: "IN",
2076: "BR",
};
const LOCATION_LANGUAGE: Record<number, string> = {
2840: "en",
2826: "en",
2276: "de",
2250: "fr",
2036: "en",
2124: "en",
2356: "en",
2076: "pt",
};
export function getLanguageCode(locationCode: number): string {
return LOCATION_LANGUAGE[locationCode] ?? "en";
}
export function scoreTierClass(value: number | null): string { export function scoreTierClass(value: number | null): string {
if (value == null) return "score-tier-na"; if (value == null) return "score-tier-na";

View File

@ -6,6 +6,8 @@ const STANDARD_MESSAGES: Record<ErrorCode, string> = {
"OpenSEO auth is not configured. Follow the README setup steps for Cloudflare Access.", "OpenSEO auth is not configured. Follow the README setup steps for Cloudflare Access.",
FORBIDDEN: "You do not have access to this resource.", FORBIDDEN: "You do not have access to this resource.",
NOT_FOUND: "The requested resource was not found.", NOT_FOUND: "The requested resource was not found.",
AUDIT_CAPACITY_REACHED:
"You've reached audit capacity for your account. Delete old audits from your projects to start a new one.",
VALIDATION_ERROR: "Please check your input and try again.", VALIDATION_ERROR: "Please check your input and try again.",
CRAWL_TARGET_BLOCKED: "This crawl target is blocked by security policy.", CRAWL_TARGET_BLOCKED: "This crawl target is blocked by security policy.",
RATE_LIMITED: "Too many requests. Please wait and try again.", RATE_LIMITED: "Too many requests. Please wait and try again.",

View File

@ -1,4 +1,5 @@
import { createFileRoute, redirect } from "@tanstack/react-router"; import { createFileRoute, redirect } from "@tanstack/react-router";
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
import { KeywordResearchPage } from "@/client/features/keywords/page/KeywordResearchPage"; import { KeywordResearchPage } from "@/client/features/keywords/page/KeywordResearchPage";
import { import {
isResultLimit, isResultLimit,
@ -27,20 +28,23 @@ export const Route = createFileRoute("/p/$projectId/keywords")({
function KeywordResearchPageRoute() { function KeywordResearchPageRoute() {
const { projectId } = Route.useParams(); const { projectId } = Route.useParams();
const search = Route.useSearch();
const { const {
q: keywordInput = "", q: keywordInput = "",
loc: locationCode = 2840, loc: rawLocationCode,
kLimit: resultLimit = 150, kLimit: resultLimit = 150,
mode: keywordMode = "auto", mode: keywordMode = "auto",
sort: sortField = "searchVolume", sort: sortField = "searchVolume",
order: sortDir = "desc", order: sortDir = "desc",
} = Route.useSearch(); } = search;
const locationCode = rawLocationCode ?? DEFAULT_LOCATION_CODE;
return ( return (
<KeywordResearchPage <KeywordResearchPage
projectId={projectId} projectId={projectId}
keywordInput={keywordInput} keywordInput={keywordInput}
locationCode={locationCode} locationCode={locationCode}
hasExplicitLocationCode={search.loc != null}
resultLimit={isResultLimit(resultLimit) ? resultLimit : 150} resultLimit={isResultLimit(resultLimit) ? resultLimit : 150}
keywordMode={normalizeKeywordMode(keywordMode)} keywordMode={normalizeKeywordMode(keywordMode)}
sortField={normalizeSortField(sortField)} sortField={normalizeSortField(sortField)}

View File

@ -4,7 +4,7 @@
*/ */
import { db } from "@/db"; import { db } from "@/db";
import { audits, auditPages, auditPsiResults, projects } from "@/db/schema"; import { audits, auditPages, auditPsiResults, projects } from "@/db/schema";
import { and, eq, desc } from "drizzle-orm"; import { and, desc, eq } from "drizzle-orm";
import type { PsiResult, AuditConfig } from "@/server/lib/audit/types"; import type { PsiResult, AuditConfig } from "@/server/lib/audit/types";
// ─── Create ────────────────────────────────────────────────────────────────── // ─── Create ──────────────────────────────────────────────────────────────────
@ -16,6 +16,8 @@ async function createAudit(data: {
startUrl: string; startUrl: string;
workflowInstanceId: string; workflowInstanceId: string;
config: AuditConfig; config: AuditConfig;
pagesTotal: number;
psiTotal: number;
}) { }) {
await db.insert(audits).values({ await db.insert(audits).values({
id: data.id, id: data.id,
@ -25,6 +27,9 @@ async function createAudit(data: {
workflowInstanceId: data.workflowInstanceId, workflowInstanceId: data.workflowInstanceId,
config: JSON.stringify(data.config), config: JSON.stringify(data.config),
status: "running", status: "running",
pagesTotal: data.pagesTotal,
psiTotal: data.psiTotal,
currentPhase: "discovery",
}); });
} }
@ -251,6 +256,18 @@ async function getAuditsByProjectForUser(projectId: string, userId: string) {
}); });
} }
async function getAuditCapacityUsageForUser(userId: string) {
const rows = await db.query.audits.findMany({
where: eq(audits.userId, userId),
columns: {
pagesTotal: true,
psiTotal: true,
},
});
return rows.reduce((total, row) => total + row.pagesTotal + row.psiTotal, 0);
}
async function getAuditResultsForUser(auditId: string, userId: string) { async function getAuditResultsForUser(auditId: string, userId: string) {
const audit = await getAuditForUser(auditId, userId); const audit = await getAuditForUser(auditId, userId);
if (!audit) { if (!audit) {
@ -335,6 +352,7 @@ export const AuditRepository = {
isProjectOwnedByUser, isProjectOwnedByUser,
getAuditForUser, getAuditForUser,
getAuditsByProjectForUser, getAuditsByProjectForUser,
getAuditCapacityUsageForUser,
getAuditResultsForUser, getAuditResultsForUser,
getPsiResultById, getPsiResultById,
deleteAuditForUser, deleteAuditForUser,

View File

@ -9,6 +9,11 @@ import { normalizeAndValidateStartUrl } from "@/server/lib/audit/url-policy";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import type { AuditConfig, PsiStrategy } from "@/server/lib/audit/types"; import type { AuditConfig, PsiStrategy } from "@/server/lib/audit/types";
import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository"; import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository";
import {
clampAuditMaxPages,
getEstimatedAuditCapacity,
MAX_USER_AUDIT_USAGE,
} from "@/server/features/audit/services/audit-capacity";
import { jsonCodec } from "@/shared/json"; import { jsonCodec } from "@/shared/json";
import { z } from "zod"; import { z } from "zod";
@ -34,6 +39,9 @@ async function startAudit(input: {
psiStrategy?: PsiStrategy; psiStrategy?: PsiStrategy;
psiApiKey?: string; psiApiKey?: string;
}) { }) {
const maxPages = clampAuditMaxPages(input.maxPages);
const psiStrategy = input.psiStrategy ?? "auto";
const hasProjectAccess = await AuditRepository.isProjectOwnedByUser( const hasProjectAccess = await AuditRepository.isProjectOwnedByUser(
input.projectId, input.projectId,
input.userId, input.userId,
@ -42,9 +50,22 @@ async function startAudit(input: {
throw new AppError("FORBIDDEN"); throw new AppError("FORBIDDEN");
} }
const reservation = getEstimatedAuditCapacity({
maxPages,
psiStrategy,
});
const currentUsage = await AuditRepository.getAuditCapacityUsageForUser(
input.userId,
);
if (currentUsage + reservation.total > MAX_USER_AUDIT_USAGE) {
throw new AppError("AUDIT_CAPACITY_REACHED");
}
const auditId = crypto.randomUUID(); const auditId = crypto.randomUUID();
const shouldRunPsi = (input.psiStrategy ?? "auto") !== "none"; const shouldRunPsi = psiStrategy !== "none";
let resolvedPsiApiKey = input.psiApiKey?.trim(); let resolvedPsiApiKey = input.psiApiKey?.trim();
if (shouldRunPsi && !resolvedPsiApiKey) { if (shouldRunPsi && !resolvedPsiApiKey) {
@ -60,35 +81,47 @@ async function startAudit(input: {
} }
const config: AuditConfig = { const config: AuditConfig = {
maxPages: Math.min(Math.max(input.maxPages ?? 50, 10), 10_000), maxPages,
psiStrategy: input.psiStrategy ?? "auto", psiStrategy,
// PSI key is used for Google quota/abuse control (non-billing). // PSI key is used for Google quota/abuse control (non-billing).
psiApiKey: resolvedPsiApiKey, psiApiKey: resolvedPsiApiKey,
}; };
const startUrl = await normalizeAndValidateStartUrl(input.startUrl); const startUrl = await normalizeAndValidateStartUrl(input.startUrl);
// Trigger the Cloudflare Workflow
const instance = await env.SITE_AUDIT_WORKFLOW.create({
id: auditId,
params: {
auditId,
projectId: input.projectId,
startUrl,
config,
},
});
// Create the audit row in D1
await AuditRepository.createAudit({ await AuditRepository.createAudit({
id: auditId, id: auditId,
projectId: input.projectId, projectId: input.projectId,
userId: input.userId, userId: input.userId,
startUrl, startUrl,
workflowInstanceId: instance.id, workflowInstanceId: auditId,
config, config,
pagesTotal: reservation.pagesTotal,
psiTotal: reservation.psiTotal,
}); });
// Trigger the Cloudflare Workflow
try {
await env.SITE_AUDIT_WORKFLOW.create({
id: auditId,
params: {
auditId,
projectId: input.projectId,
startUrl,
config,
},
});
} catch (error) {
try {
const instance = await env.SITE_AUDIT_WORKFLOW.get(auditId);
await instance.terminate();
} catch {
// The workflow may never have been created, or may already be gone.
}
await AuditRepository.deleteAuditForUser(auditId, input.userId);
throw error;
}
return { auditId }; return { auditId };
} }
@ -185,6 +218,24 @@ async function remove(auditId: string, userId: string) {
if (!audit) { if (!audit) {
throw new AppError("NOT_FOUND"); throw new AppError("NOT_FOUND");
} }
if (audit.status === "running") {
if (!audit.workflowInstanceId) {
throw new AppError(
"CONFLICT",
"Cannot delete a running audit without workflow context.",
);
}
try {
const instance = await env.SITE_AUDIT_WORKFLOW.get(
audit.workflowInstanceId,
);
await instance.terminate();
} catch (error) {
console.error(`Failed to terminate audit workflow ${audit.id}:`, error);
throw new AppError("CONFLICT", "Unable to stop the running audit.");
}
}
await AuditRepository.deleteAuditForUser(auditId, userId); await AuditRepository.deleteAuditForUser(auditId, userId);
} }

View File

@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import {
clampAuditMaxPages,
getEstimatedAuditCapacity,
MAX_USER_AUDIT_USAGE,
} from "@/server/features/audit/services/audit-capacity";
describe("audit capacity helpers", () => {
it("clamps max pages into the supported range", () => {
expect(clampAuditMaxPages()).toBe(50);
expect(clampAuditMaxPages(1)).toBe(10);
expect(clampAuditMaxPages(500)).toBe(500);
expect(clampAuditMaxPages(20_000)).toBe(10_000);
});
it("estimates capacity for each psi strategy", () => {
expect(
getEstimatedAuditCapacity({ maxPages: 100, psiStrategy: "none" }),
).toEqual({
pagesTotal: 100,
psiTotal: 0,
total: 100,
});
expect(
getEstimatedAuditCapacity({ maxPages: 100, psiStrategy: "manual" }),
).toEqual({
pagesTotal: 100,
psiTotal: 0,
total: 100,
});
expect(
getEstimatedAuditCapacity({ maxPages: 100, psiStrategy: "auto" }),
).toEqual({
pagesTotal: 100,
psiTotal: 20,
total: 120,
});
expect(
getEstimatedAuditCapacity({ maxPages: 100, psiStrategy: "all" }),
).toEqual({
pagesTotal: 100,
psiTotal: 200,
total: 300,
});
});
it("stays within the global capacity limit for the maximum auto audit", () => {
expect(
getEstimatedAuditCapacity({ maxPages: 10_000, psiStrategy: "auto" })
.total,
).toBeLessThan(MAX_USER_AUDIT_USAGE);
});
});

View File

@ -0,0 +1,35 @@
import type { PsiStrategy } from "@/server/lib/audit/types";
export const MAX_USER_AUDIT_USAGE = 100_000;
export function clampAuditMaxPages(maxPages?: number) {
return Math.min(Math.max(maxPages ?? 50, 10), 10_000);
}
export function getEstimatedAuditCapacity(input: {
maxPages?: number;
psiStrategy?: PsiStrategy;
}) {
const pagesTotal = clampAuditMaxPages(input.maxPages);
const psiStrategy = input.psiStrategy ?? "auto";
let psiTotal = 0;
switch (psiStrategy) {
case "all":
psiTotal = pagesTotal * 2;
break;
case "auto":
psiTotal = 20;
break;
case "manual":
case "none":
psiTotal = 0;
break;
}
return {
pagesTotal,
psiTotal,
total: pagesTotal + psiTotal,
};
}

View File

@ -5,6 +5,7 @@ const ERROR_CODES = [
"AUTH_CONFIG_MISSING", "AUTH_CONFIG_MISSING",
"FORBIDDEN", "FORBIDDEN",
"NOT_FOUND", "NOT_FOUND",
"AUDIT_CAPACITY_REACHED",
"VALIDATION_ERROR", "VALIDATION_ERROR",
"CRAWL_TARGET_BLOCKED", "CRAWL_TARGET_BLOCKED",
"RATE_LIMITED", "RATE_LIMITED",