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,
|
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",
|
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 = {
|
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,
|
||||||
|
|||||||
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,
|
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;
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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.",
|
||||||
|
|||||||
@ -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)}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,16 +81,28 @@ 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);
|
||||||
|
|
||||||
|
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
|
// Trigger the Cloudflare Workflow
|
||||||
const instance = await env.SITE_AUDIT_WORKFLOW.create({
|
try {
|
||||||
|
await env.SITE_AUDIT_WORKFLOW.create({
|
||||||
id: auditId,
|
id: auditId,
|
||||||
params: {
|
params: {
|
||||||
auditId,
|
auditId,
|
||||||
@ -78,16 +111,16 @@ async function startAudit(input: {
|
|||||||
config,
|
config,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
// Create the audit row in D1
|
try {
|
||||||
await AuditRepository.createAudit({
|
const instance = await env.SITE_AUDIT_WORKFLOW.get(auditId);
|
||||||
id: auditId,
|
await instance.terminate();
|
||||||
projectId: input.projectId,
|
} catch {
|
||||||
userId: input.userId,
|
// The workflow may never have been created, or may already be gone.
|
||||||
startUrl,
|
}
|
||||||
workflowInstanceId: instance.id,
|
await AuditRepository.deleteAuditForUser(auditId, input.userId);
|
||||||
config,
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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",
|
"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",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user