diff --git a/src/client/features/audit/launch/useLaunchController.ts b/src/client/features/audit/launch/useLaunchController.ts index 46f709f..7c9ffd9 100644 --- a/src/client/features/audit/launch/useLaunchController.ts +++ b/src/client/features/audit/launch/useLaunchController.ts @@ -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"), })); }, }, diff --git a/src/client/features/keywords/hooks/useKeywordControlsForm.ts b/src/client/features/keywords/hooks/useKeywordControlsForm.ts index 2f371ff..1ca66e6 100644 --- a/src/client/features/keywords/hooks/useKeywordControlsForm.ts +++ b/src/client/features/keywords/hooks/useKeywordControlsForm.ts @@ -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; } diff --git a/src/client/features/keywords/hooks/usePreferredKeywordLocation.ts b/src/client/features/keywords/hooks/usePreferredKeywordLocation.ts new file mode 100644 index 0000000..a27dcd4 --- /dev/null +++ b/src/client/features/keywords/hooks/usePreferredKeywordLocation.ts @@ -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 }; +} diff --git a/src/client/features/keywords/keywordSearchParams.ts b/src/client/features/keywords/keywordSearchParams.ts index 37257b3..09ff9d8 100644 --- a/src/client/features/keywords/keywordSearchParams.ts +++ b/src/client/features/keywords/keywordSearchParams.ts @@ -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, diff --git a/src/client/features/keywords/locations.ts b/src/client/features/keywords/locations.ts new file mode 100644 index 0000000..914cbf8 --- /dev/null +++ b/src/client/features/keywords/locations.ts @@ -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( + LOCATION_OPTIONS.map((option) => option.code), +); + +export const LOCATIONS: Record = Object.fromEntries( + LOCATION_OPTIONS.map((option) => [option.code, option.shortLabel]), +); + +const LOCATION_LANGUAGE: Record = 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); +} diff --git a/src/client/features/keywords/page/KeywordResearchSearchBar.tsx b/src/client/features/keywords/page/KeywordResearchSearchBar.tsx index c690d23..7e3b842 100644 --- a/src/client/features/keywords/page/KeywordResearchSearchBar.tsx +++ b/src/client/features/keywords/page/KeywordResearchSearchBar.tsx @@ -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; diff --git a/src/client/features/keywords/state/keywordControllerActions.ts b/src/client/features/keywords/state/keywordControllerActions.ts index 583787d..1062d14 100644 --- a/src/client/features/keywords/state/keywordControllerActions.ts +++ b/src/client/features/keywords/state/keywordControllerActions.ts @@ -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, ) => 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, }); diff --git a/src/client/features/keywords/state/useKeywordResearchController.ts b/src/client/features/keywords/state/useKeywordResearchController.ts index e8417ca..459dd22 100644 --- a/src/client/features/keywords/state/useKeywordResearchController.ts +++ b/src/client/features/keywords/state/useKeywordResearchController.ts @@ -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(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(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(null); + const [searchInputError, setSearchInputError] = useState(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>(state: T): T { return state; } + +function buildKeywordControllerState>( + state: T, +): T { + return state; +} diff --git a/src/client/features/keywords/utils.ts b/src/client/features/keywords/utils.ts index 922d980..1f3bc4a 100644 --- a/src/client/features/keywords/utils.ts +++ b/src/client/features/keywords/utils.ts @@ -1,28 +1,4 @@ -export const LOCATIONS: Record = { - 2840: "US", - 2826: "UK", - 2276: "DE", - 2250: "FR", - 2036: "AU", - 2124: "CA", - 2356: "IN", - 2076: "BR", -}; - -const LOCATION_LANGUAGE: Record = { - 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"; diff --git a/src/client/lib/error-messages.ts b/src/client/lib/error-messages.ts index 50f8c5c..c1e3a40 100644 --- a/src/client/lib/error-messages.ts +++ b/src/client/lib/error-messages.ts @@ -6,6 +6,8 @@ const STANDARD_MESSAGES: Record = { "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.", diff --git a/src/routes/p/$projectId/keywords.tsx b/src/routes/p/$projectId/keywords.tsx index 4167d1e..4d198cc 100644 --- a/src/routes/p/$projectId/keywords.tsx +++ b/src/routes/p/$projectId/keywords.tsx @@ -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 ( 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, diff --git a/src/server/features/audit/services/AuditService.ts b/src/server/features/audit/services/AuditService.ts index ae4062f..ee8aa1c 100644 --- a/src/server/features/audit/services/AuditService.ts +++ b/src/server/features/audit/services/AuditService.ts @@ -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,35 +81,47 @@ 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); - // 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({ id: auditId, projectId: input.projectId, userId: input.userId, startUrl, - workflowInstanceId: instance.id, + workflowInstanceId: auditId, 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 }; } @@ -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); } diff --git a/src/server/features/audit/services/audit-capacity.test.ts b/src/server/features/audit/services/audit-capacity.test.ts new file mode 100644 index 0000000..a38a6e4 --- /dev/null +++ b/src/server/features/audit/services/audit-capacity.test.ts @@ -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); + }); +}); diff --git a/src/server/features/audit/services/audit-capacity.ts b/src/server/features/audit/services/audit-capacity.ts new file mode 100644 index 0000000..97f6da5 --- /dev/null +++ b/src/server/features/audit/services/audit-capacity.ts @@ -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, + }; +} diff --git a/src/shared/error-codes.ts b/src/shared/error-codes.ts index 923f9e8..fbb6e40 100644 --- a/src/shared/error-codes.ts +++ b/src/shared/error-codes.ts @@ -5,6 +5,7 @@ const ERROR_CODES = [ "AUTH_CONFIG_MISSING", "FORBIDDEN", "NOT_FOUND", + "AUDIT_CAPACITY_REACHED", "VALIDATION_ERROR", "CRAWL_TARGET_BLOCKED", "RATE_LIMITED",