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:
parent
7b4f85dc10
commit
c0d64f366e
@ -18,6 +18,7 @@ import {
|
||||
useSettingsForm,
|
||||
type LaunchState,
|
||||
} from "@/client/features/audit/launch/types";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
|
||||
export function useLaunchController({
|
||||
projectId,
|
||||
@ -116,8 +117,7 @@ export function useLaunchController({
|
||||
onError: (error) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
startError:
|
||||
error instanceof Error ? error.message : "Failed to start audit",
|
||||
startError: getStandardErrorMessage(error, "Failed to start audit"),
|
||||
}));
|
||||
},
|
||||
},
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import { useEffect } from "react";
|
||||
import type {
|
||||
KeywordMode,
|
||||
ResultLimit,
|
||||
@ -12,7 +13,7 @@ type UseKeywordControlsFormInput = {
|
||||
};
|
||||
|
||||
export function useKeywordControlsForm(input: UseKeywordControlsFormInput) {
|
||||
return useForm({
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
keyword: input.keywordInput,
|
||||
locationCode: input.locationCode,
|
||||
@ -20,4 +21,19 @@ export function useKeywordControlsForm(input: UseKeywordControlsFormInput) {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
@ -28,7 +28,7 @@ export function normalizeLegacyKeywordSearch(search: KeywordSearchParams): {
|
||||
const normalized: KeywordSearchParams = {
|
||||
...search,
|
||||
q: search.q === "" ? undefined : search.q,
|
||||
loc: search.loc === 2840 ? undefined : search.loc,
|
||||
loc: search.loc,
|
||||
kLimit: search.kLimit === 150 ? undefined : search.kLimit,
|
||||
mode: search.mode === "auto" ? undefined : search.mode,
|
||||
sort: search.sort === "searchVolume" ? undefined : search.sort,
|
||||
|
||||
70
src/client/features/keywords/locations.ts
Normal file
70
src/client/features/keywords/locations.ts
Normal 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);
|
||||
}
|
||||
@ -4,23 +4,13 @@ import {
|
||||
normalizeKeywordMode,
|
||||
} from "@/client/features/keywords/keywordSearchParams";
|
||||
import { RESULT_LIMITS } from "@/client/features/keywords/keywordResearchTypes";
|
||||
import { LOCATION_OPTIONS } from "@/client/features/keywords/locations";
|
||||
import type { KeywordResearchControllerState } from "./types";
|
||||
|
||||
type Props = {
|
||||
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) {
|
||||
const { controlsForm, handleSearchSubmit, isLoading, searchInputError } =
|
||||
controller;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { type FormEvent } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
|
||||
import { buildCsv, downloadCsv } from "@/client/lib/csv";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import type {
|
||||
@ -48,6 +49,7 @@ type SearchActionParams = {
|
||||
setSearchParams: (
|
||||
updates: Record<string, string | number | boolean | undefined>,
|
||||
) => void;
|
||||
setPreferredLocationCode: (locationCode: number) => void;
|
||||
};
|
||||
|
||||
type SaveExportActionParams = {
|
||||
@ -96,6 +98,7 @@ export function useSearchActions(params: SearchActionParams) {
|
||||
setSerpPage,
|
||||
setSearchInputError,
|
||||
setSearchParams,
|
||||
setPreferredLocationCode,
|
||||
} = params;
|
||||
|
||||
const onSearch = (
|
||||
@ -120,9 +123,14 @@ export function useSearchActions(params: SearchActionParams) {
|
||||
}
|
||||
|
||||
setSearchInputError(null);
|
||||
setPreferredLocationCode(activeLocation);
|
||||
setSearchParams({
|
||||
q: inputKeyword,
|
||||
loc: activeLocation === 2840 ? undefined : activeLocation,
|
||||
loc:
|
||||
input.hasExplicitLocationCode ||
|
||||
activeLocation !== DEFAULT_LOCATION_CODE
|
||||
? activeLocation
|
||||
: undefined,
|
||||
kLimit: activeResultLimit === 150 ? undefined : activeResultLimit,
|
||||
mode: activeMode === "auto" ? undefined : activeMode,
|
||||
});
|
||||
|
||||
@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useKeywordControlsForm } from "@/client/features/keywords/hooks/useKeywordControlsForm";
|
||||
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 { useKeywordResearchData } from "@/client/features/keywords/hooks/useKeywordResearchData";
|
||||
import { useKeywordSelection } from "@/client/features/keywords/hooks/useKeywordSelection";
|
||||
@ -25,6 +26,7 @@ export type KeywordResearchControllerInput = {
|
||||
projectId: string;
|
||||
keywordInput: string;
|
||||
locationCode: number;
|
||||
hasExplicitLocationCode: boolean;
|
||||
resultLimit: ResultLimit;
|
||||
keywordMode: KeywordMode;
|
||||
sortField: SortField;
|
||||
@ -47,6 +49,7 @@ export function useKeywordResearchController(
|
||||
setSerpPage: state.setSerpPage,
|
||||
setSearchInputError: state.setSearchInputError,
|
||||
setSearchParams: state.setSearchParams,
|
||||
setPreferredLocationCode: state.setPreferredLocationCode,
|
||||
});
|
||||
|
||||
const { handleSaveKeywords, confirmSave, exportCsv } =
|
||||
@ -122,11 +125,14 @@ export function useKeywordResearchController(
|
||||
}
|
||||
|
||||
function useKeywordControllerState(input: KeywordResearchControllerInput) {
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [selectedKeyword, setSelectedKeyword] =
|
||||
useState<KeywordResearchRow | null>(null);
|
||||
const uiState = useKeywordUiState();
|
||||
const { locationCode, setPreferredLocationCode } =
|
||||
useResolvedKeywordLocation(input);
|
||||
|
||||
const controlsForm = useKeywordControlsForm(input);
|
||||
const controlsForm = useKeywordControlsForm({
|
||||
...input,
|
||||
locationCode,
|
||||
});
|
||||
const {
|
||||
filtersForm,
|
||||
values: filterValues,
|
||||
@ -144,7 +150,7 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
|
||||
activeSerpKeyword,
|
||||
serpLoading,
|
||||
serpError,
|
||||
} = useKeywordSerpAnalysis(input.locationCode);
|
||||
} = useKeywordSerpAnalysis(locationCode);
|
||||
|
||||
const {
|
||||
history,
|
||||
@ -168,9 +174,6 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
|
||||
beginSearch,
|
||||
runSearch,
|
||||
} = 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 saveMutation = useKeywordSaveMutation(input.projectId);
|
||||
|
||||
@ -185,14 +188,14 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
|
||||
useKeywordOverviewState({
|
||||
rows,
|
||||
searchedKeyword,
|
||||
selectedKeyword,
|
||||
selectedKeyword: uiState.selectedKeyword,
|
||||
hasSearched,
|
||||
isLoading,
|
||||
lastSearchError,
|
||||
keywordMode: input.keywordMode,
|
||||
});
|
||||
|
||||
return {
|
||||
return buildKeywordControllerState({
|
||||
activeFilterCount,
|
||||
activeSerpKeyword,
|
||||
beginSearch,
|
||||
@ -210,7 +213,7 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
|
||||
lastSearchKeyword,
|
||||
lastSearchLocationCode,
|
||||
lastUsedFallback,
|
||||
mobileTab,
|
||||
mobileTab: uiState.mobileTab,
|
||||
overviewKeyword,
|
||||
removeHistoryItem,
|
||||
researchError,
|
||||
@ -218,11 +221,12 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
|
||||
resetFilters,
|
||||
rows,
|
||||
searchedKeyword,
|
||||
searchInputError,
|
||||
selectedKeyword,
|
||||
searchInputError: uiState.searchInputError,
|
||||
selectedKeyword: uiState.selectedKeyword,
|
||||
selectedRows,
|
||||
saveMutation,
|
||||
setSelectedKeyword,
|
||||
setPreferredLocationCode,
|
||||
setSelectedKeyword: uiState.setSelectedKeyword,
|
||||
setSearchParams,
|
||||
setSerpKeyword,
|
||||
serpError,
|
||||
@ -230,17 +234,50 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
|
||||
serpPage,
|
||||
serpQuery,
|
||||
serpResults,
|
||||
setMobileTab,
|
||||
setSearchInputError,
|
||||
setMobileTab: uiState.setMobileTab,
|
||||
setSearchInputError: uiState.setSearchInputError,
|
||||
setSerpPage,
|
||||
setShowFilters,
|
||||
setShowSaveDialog,
|
||||
setShowFilters: uiState.setShowFilters,
|
||||
setShowSaveDialog: uiState.setShowSaveDialog,
|
||||
showApproximateMatchNotice,
|
||||
showFilters,
|
||||
showSaveDialog,
|
||||
showFilters: uiState.showFilters,
|
||||
showSaveDialog: uiState.showSaveDialog,
|
||||
toggleAllRows,
|
||||
toggleRowSelection,
|
||||
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 {
|
||||
return state;
|
||||
}
|
||||
|
||||
function buildKeywordControllerState<T extends Record<string, unknown>>(
|
||||
state: T,
|
||||
): T {
|
||||
return state;
|
||||
}
|
||||
|
||||
@ -1,28 +1,4 @@
|
||||
export const LOCATIONS: Record<number, string> = {
|
||||
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 { LOCATIONS, getLanguageCode } from "./locations";
|
||||
|
||||
export function scoreTierClass(value: number | null): string {
|
||||
if (value == null) return "score-tier-na";
|
||||
|
||||
@ -6,6 +6,8 @@ const STANDARD_MESSAGES: Record<ErrorCode, string> = {
|
||||
"OpenSEO auth is not configured. Follow the README setup steps for Cloudflare Access.",
|
||||
FORBIDDEN: "You do not have access to this resource.",
|
||||
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.",
|
||||
CRAWL_TARGET_BLOCKED: "This crawl target is blocked by security policy.",
|
||||
RATE_LIMITED: "Too many requests. Please wait and try again.",
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
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 {
|
||||
isResultLimit,
|
||||
@ -27,20 +28,23 @@ export const Route = createFileRoute("/p/$projectId/keywords")({
|
||||
|
||||
function KeywordResearchPageRoute() {
|
||||
const { projectId } = Route.useParams();
|
||||
const search = Route.useSearch();
|
||||
const {
|
||||
q: keywordInput = "",
|
||||
loc: locationCode = 2840,
|
||||
loc: rawLocationCode,
|
||||
kLimit: resultLimit = 150,
|
||||
mode: keywordMode = "auto",
|
||||
sort: sortField = "searchVolume",
|
||||
order: sortDir = "desc",
|
||||
} = Route.useSearch();
|
||||
} = search;
|
||||
const locationCode = rawLocationCode ?? DEFAULT_LOCATION_CODE;
|
||||
|
||||
return (
|
||||
<KeywordResearchPage
|
||||
projectId={projectId}
|
||||
keywordInput={keywordInput}
|
||||
locationCode={locationCode}
|
||||
hasExplicitLocationCode={search.loc != null}
|
||||
resultLimit={isResultLimit(resultLimit) ? resultLimit : 150}
|
||||
keywordMode={normalizeKeywordMode(keywordMode)}
|
||||
sortField={normalizeSortField(sortField)}
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
*/
|
||||
import { db } from "@/db";
|
||||
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";
|
||||
|
||||
// ─── Create ──────────────────────────────────────────────────────────────────
|
||||
@ -16,6 +16,8 @@ async function createAudit(data: {
|
||||
startUrl: string;
|
||||
workflowInstanceId: string;
|
||||
config: AuditConfig;
|
||||
pagesTotal: number;
|
||||
psiTotal: number;
|
||||
}) {
|
||||
await db.insert(audits).values({
|
||||
id: data.id,
|
||||
@ -25,6 +27,9 @@ async function createAudit(data: {
|
||||
workflowInstanceId: data.workflowInstanceId,
|
||||
config: JSON.stringify(data.config),
|
||||
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) {
|
||||
const audit = await getAuditForUser(auditId, userId);
|
||||
if (!audit) {
|
||||
@ -335,6 +352,7 @@ export const AuditRepository = {
|
||||
isProjectOwnedByUser,
|
||||
getAuditForUser,
|
||||
getAuditsByProjectForUser,
|
||||
getAuditCapacityUsageForUser,
|
||||
getAuditResultsForUser,
|
||||
getPsiResultById,
|
||||
deleteAuditForUser,
|
||||
|
||||
@ -9,6 +9,11 @@ import { normalizeAndValidateStartUrl } from "@/server/lib/audit/url-policy";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import type { AuditConfig, PsiStrategy } from "@/server/lib/audit/types";
|
||||
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 { z } from "zod";
|
||||
|
||||
@ -34,6 +39,9 @@ async function startAudit(input: {
|
||||
psiStrategy?: PsiStrategy;
|
||||
psiApiKey?: string;
|
||||
}) {
|
||||
const maxPages = clampAuditMaxPages(input.maxPages);
|
||||
const psiStrategy = input.psiStrategy ?? "auto";
|
||||
|
||||
const hasProjectAccess = await AuditRepository.isProjectOwnedByUser(
|
||||
input.projectId,
|
||||
input.userId,
|
||||
@ -42,9 +50,22 @@ async function startAudit(input: {
|
||||
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 shouldRunPsi = (input.psiStrategy ?? "auto") !== "none";
|
||||
const shouldRunPsi = psiStrategy !== "none";
|
||||
let resolvedPsiApiKey = input.psiApiKey?.trim();
|
||||
|
||||
if (shouldRunPsi && !resolvedPsiApiKey) {
|
||||
@ -60,16 +81,28 @@ async function startAudit(input: {
|
||||
}
|
||||
|
||||
const config: AuditConfig = {
|
||||
maxPages: Math.min(Math.max(input.maxPages ?? 50, 10), 10_000),
|
||||
psiStrategy: input.psiStrategy ?? "auto",
|
||||
maxPages,
|
||||
psiStrategy,
|
||||
// PSI key is used for Google quota/abuse control (non-billing).
|
||||
psiApiKey: resolvedPsiApiKey,
|
||||
};
|
||||
|
||||
const startUrl = await normalizeAndValidateStartUrl(input.startUrl);
|
||||
|
||||
await AuditRepository.createAudit({
|
||||
id: auditId,
|
||||
projectId: input.projectId,
|
||||
userId: input.userId,
|
||||
startUrl,
|
||||
workflowInstanceId: auditId,
|
||||
config,
|
||||
pagesTotal: reservation.pagesTotal,
|
||||
psiTotal: reservation.psiTotal,
|
||||
});
|
||||
|
||||
// Trigger the Cloudflare Workflow
|
||||
const instance = await env.SITE_AUDIT_WORKFLOW.create({
|
||||
try {
|
||||
await env.SITE_AUDIT_WORKFLOW.create({
|
||||
id: auditId,
|
||||
params: {
|
||||
auditId,
|
||||
@ -78,16 +111,16 @@ async function startAudit(input: {
|
||||
config,
|
||||
},
|
||||
});
|
||||
|
||||
// Create the audit row in D1
|
||||
await AuditRepository.createAudit({
|
||||
id: auditId,
|
||||
projectId: input.projectId,
|
||||
userId: input.userId,
|
||||
startUrl,
|
||||
workflowInstanceId: instance.id,
|
||||
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 };
|
||||
}
|
||||
@ -185,6 +218,24 @@ async function remove(auditId: string, userId: string) {
|
||||
if (!audit) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
53
src/server/features/audit/services/audit-capacity.test.ts
Normal file
53
src/server/features/audit/services/audit-capacity.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
35
src/server/features/audit/services/audit-capacity.ts
Normal file
35
src/server/features/audit/services/audit-capacity.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -5,6 +5,7 @@ const ERROR_CODES = [
|
||||
"AUTH_CONFIG_MISSING",
|
||||
"FORBIDDEN",
|
||||
"NOT_FOUND",
|
||||
"AUDIT_CAPACITY_REACHED",
|
||||
"VALIDATION_ERROR",
|
||||
"CRAWL_TARGET_BLOCKED",
|
||||
"RATE_LIMITED",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user