From c3caf2009f058d5c936bea88e8dbda5b3fc96e07 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:25:33 -0400 Subject: [PATCH] Remove backlinks + LLM-mentions access gates --- .../features/access-gate/AccessGate.tsx | 83 ----------------- .../features/access-gate/useAccessGate.ts | 46 ---------- .../features/ai-search/BrandLookupPage.tsx | 22 +---- .../features/ai-search/PromptExplorerPage.tsx | 24 +---- .../components/AiSearchSetupGate.tsx | 43 --------- .../features/ai-search/useAiSearchAccess.ts | 10 -- .../features/backlinks/BacklinksPage.tsx | 34 +++---- .../backlinks/BacklinksPageContent.tsx | 30 ------ .../backlinks/BacklinksPageStates.tsx | 44 --------- .../backlinks/useBacklinksPageData.ts | 33 +------ src/client/features/sam/SamChat.tsx | 24 ++--- src/client/features/sam/SamSetupGate.tsx | 81 +++++++++++------ src/client/features/sam/useSamAccess.ts | 58 +++++++++++- src/client/lib/error-messages.ts | 4 - .../ai-search/services/brandLookup.ts | 1 - .../ai-search/services/promptExplorer.ts | 1 - src/server/lib/dataforseo/ai.ts | 7 +- src/server/lib/dataforseo/backlinks.test.ts | 22 ++--- src/server/lib/dataforseo/backlinks.ts | 7 +- src/server/lib/dataforseo/core.ts | 6 +- src/server/lib/dataforseo/envelope.test.ts | 20 ++-- src/server/lib/dataforseoAccountState.ts | 91 ------------------- ...> dataforseoBillingClassification.test.ts} | 41 +++------ ....ts => dataforseoBillingClassification.ts} | 38 ++------ src/serverFunctions/aiSearchAccess.ts | 34 ------- src/serverFunctions/backlinksAccess.ts | 34 ------- src/shared/error-codes.test.ts | 8 +- src/shared/error-codes.ts | 2 - src/types/schemas/ai-search.ts | 8 -- src/types/schemas/backlinks.ts | 4 - 30 files changed, 200 insertions(+), 660 deletions(-) delete mode 100644 src/client/features/access-gate/AccessGate.tsx delete mode 100644 src/client/features/access-gate/useAccessGate.ts delete mode 100644 src/client/features/ai-search/components/AiSearchSetupGate.tsx delete mode 100644 src/client/features/ai-search/useAiSearchAccess.ts delete mode 100644 src/server/lib/dataforseoAccountState.ts rename src/server/lib/{dataforseoAccessClassification.test.ts => dataforseoBillingClassification.test.ts} (55%) rename src/server/lib/{dataforseoAccessClassification.ts => dataforseoBillingClassification.ts} (54%) delete mode 100644 src/serverFunctions/aiSearchAccess.ts delete mode 100644 src/serverFunctions/backlinksAccess.ts diff --git a/src/client/features/access-gate/AccessGate.tsx b/src/client/features/access-gate/AccessGate.tsx deleted file mode 100644 index 3e0ed56..0000000 --- a/src/client/features/access-gate/AccessGate.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import type { ReactNode } from "react"; -import { ShieldAlert, Wrench } from "lucide-react"; - -export function AccessGateLoadingState() { - return ( -
-
-
-
-
-
-
-
- ); -} - -export function AccessGate({ - title, - bodyText, - helperText, - buttonLabel, - refetchingLabel = "Confirming...", - externalUrl, - externalLabel, - errorMessage, - isRefetching, - onRetry, -}: { - title: string; - bodyText: ReactNode; - helperText?: ReactNode; - buttonLabel: string; - refetchingLabel?: string; - externalUrl: string; - externalLabel: string; - errorMessage: string | null; - isRefetching: boolean; - onRetry: () => void; -}) { - return ( -
-
-
-
- -
-
-

{title}

-
{bodyText}
- {helperText ? ( -
{helperText}
- ) : null} -
-
- -
- - - {externalLabel} - -
- - {errorMessage ? ( -
- - {errorMessage} -
- ) : null} -
-
- ); -} diff --git a/src/client/features/access-gate/useAccessGate.ts b/src/client/features/access-gate/useAccessGate.ts deleted file mode 100644 index 038659b..0000000 --- a/src/client/features/access-gate/useAccessGate.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { useCallback } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { getStandardErrorMessage } from "@/client/lib/error-messages"; - -type AccessGateStatus = { - enabled: boolean; - errorMessage: string | null; -}; - -export type UseAccessGateResult = { - enabled: boolean; - isLoading: boolean; - isRefetching: boolean; - errorMessage: string | null; - statusErrorMessage: string | null; - onRetry: () => void; -}; - -export function useAccessGate(config: { - queryKey: readonly unknown[]; - queryFn: () => Promise; - statusErrorFallback: string; -}): UseAccessGateResult { - const { data, error, isPending, isRefetching, refetch } = useQuery({ - queryKey: config.queryKey, - queryFn: config.queryFn, - refetchOnWindowFocus: false, - staleTime: 60 * 1000, - }); - - const statusErrorMessage = error - ? getStandardErrorMessage(error, config.statusErrorFallback) - : null; - const onRetry = useCallback(() => { - void refetch(); - }, [refetch]); - - return { - enabled: data?.enabled ?? false, - isLoading: isPending, - isRefetching, - errorMessage: data?.errorMessage ?? null, - statusErrorMessage, - onRetry, - }; -} diff --git a/src/client/features/ai-search/BrandLookupPage.tsx b/src/client/features/ai-search/BrandLookupPage.tsx index f6eb70d..9540ba9 100644 --- a/src/client/features/ai-search/BrandLookupPage.tsx +++ b/src/client/features/ai-search/BrandLookupPage.tsx @@ -19,9 +19,6 @@ import { BrandLookupSearchCard } from "@/client/features/ai-search/components/Br import { BrandLookupHistorySection } from "@/client/features/ai-search/components/BrandLookupHistorySection"; import { AiSearchLoadingState } from "@/client/features/ai-search/components/AiSearchLoadingState"; import { AiSearchPaidPlanGate } from "@/client/features/ai-search/components/AiSearchPaidPlanGate"; -import { AiSearchSetupGate } from "@/client/features/ai-search/components/AiSearchSetupGate"; -import { AccessGateLoadingState } from "@/client/features/access-gate/AccessGate"; -import { useAiSearchAccess } from "@/client/features/ai-search/useAiSearchAccess"; import { useBrandLookupSearchHistory } from "@/client/hooks/useBrandLookupSearchHistory"; import { BRAND_LOOKUP_MAX_INPUT_LENGTH, @@ -80,8 +77,6 @@ function BrandLookupPageInner({ message: string; } | null>(null); - const access = useAiSearchAccess(projectId); - const trimmedInitialQuery = initialQuery.trim(); const hasActiveQuery = trimmedInitialQuery.length > 0; // The URL `c` param is the source of truth for the active lookup; the local @@ -101,7 +96,10 @@ function BrandLookupPageInner({ languageCode: "en", }, }), - enabled: hasActiveQuery && !planGate.isFreePlan && access.enabled, + // Client-side gate is a UX optimization only; the paywall is enforced + // server-side (lookupBrand → assertPaidPlan) before any DataForSEO spend, + // so a stale free-plan window here just yields a rejected request, not cost. + enabled: hasActiveQuery && !planGate.isFreePlan, staleTime: 5 * 60 * 1000, retry: false, }); @@ -200,8 +198,6 @@ function BrandLookupPageInner({ : null; const resultData = hasActiveQuery ? lookupQuery.data : undefined; - if (planGate.isLoading) return null; - return (
@@ -212,15 +208,7 @@ function BrandLookupPageInner({

- {access.isLoading ? ( - - ) : !access.enabled ? ( - - ) : planGate.isFreePlan ? ( + {planGate.isFreePlan ? ( (urlState); const [validationError, setValidationError] = useState(null); - const access = useAiSearchAccess(projectId); const { history, @@ -110,11 +106,11 @@ function PromptExplorerPageInner({ webSearchCountryCode: urlState.webSearchCountryCode, }, }), + // Client-side gate is a UX optimization only; the paywall is enforced + // server-side (explorePrompt → assertPaidPlan) before any DataForSEO spend, + // so a stale free-plan window here just yields a rejected request, not cost. enabled: - hasActivePrompt && - urlState.models.length > 0 && - !planGate.isFreePlan && - access.enabled, + hasActivePrompt && urlState.models.length > 0 && !planGate.isFreePlan, staleTime: 5 * 60 * 1000, retry: false, }); @@ -199,8 +195,6 @@ function PromptExplorerPageInner({ if (validationError) setValidationError(null); }; - if (planGate.isLoading) return null; - return (
@@ -212,15 +206,7 @@ function PromptExplorerPageInner({

- {access.isLoading ? ( - - ) : !access.enabled ? ( - - ) : planGate.isFreePlan ? ( + {planGate.isFreePlan ? ( void; -}) { - return ( - - We are also planning an API so self-hosted apps can use OpenSEO's LLM - Mentions data directly. Until then, . - - } - buttonLabel="Confirm AI Optimization Access" - externalUrl="https://app.dataforseo.com/api-access-subscriptions" - externalLabel="Open DataForSEO API Access" - errorMessage={errorMessage} - isRefetching={isRefetching} - onRetry={onRetry} - /> - ); -} - -function InlineManagedOpenSeoLink() { - return ( - - use managed OpenSEO - - ); -} diff --git a/src/client/features/ai-search/useAiSearchAccess.ts b/src/client/features/ai-search/useAiSearchAccess.ts deleted file mode 100644 index a720346..0000000 --- a/src/client/features/ai-search/useAiSearchAccess.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { useAccessGate } from "@/client/features/access-gate/useAccessGate"; -import { getAiSearchAccessSetupStatus } from "@/serverFunctions/aiSearchAccess"; - -export function useAiSearchAccess(projectId: string) { - return useAccessGate({ - queryKey: ["aiSearchAccessStatus", projectId], - queryFn: () => getAiSearchAccessSetupStatus({ data: { projectId } }), - statusErrorFallback: "Could not load AI Optimization setup status.", - }); -} diff --git a/src/client/features/backlinks/BacklinksPage.tsx b/src/client/features/backlinks/BacklinksPage.tsx index f0c50b3..c38cf98 100644 --- a/src/client/features/backlinks/BacklinksPage.tsx +++ b/src/client/features/backlinks/BacklinksPage.tsx @@ -101,10 +101,8 @@ export function BacklinksPage({ }); const { - accessGate, activeTabErrorMessage, activeTabQuery, - backlinksDisabledByError, overviewErrorMessage, overviewQuery, referringDomainsQuery, @@ -192,28 +190,22 @@ export function BacklinksPage({

- {!accessGate.isLoading && - accessGate.enabled && - !backlinksDisabledByError ? ( - - searchTabs.canOpenTab(toBacklinksTabInput(values)) - } - tabLimit={searchTabs.limit} - onSubmit={(values) => { - searchTabs.openTab(toBacklinksTabInput(values)); - navigateToBacklinksSearch(navigate, values); - addSearch({ target: values.target, scope: values.scope }); - }} - /> - ) : null} + + searchTabs.canOpenTab(toBacklinksTabInput(values)) + } + tabLimit={searchTabs.limit} + onSubmit={(values) => { + searchTabs.openTab(toBacklinksTabInput(values)); + navigateToBacklinksSearch(navigate, values); + addSearch({ target: values.target, scope: values.scope }); + }} + /> ) : null; - if (accessGate.isLoading) { - return ; - } - - if (accessGate.statusErrorMessage) { - return ( - - ); - } - - if (!accessGate.enabled || backlinksDisabledByError) { - return ( - - ); - } - if (!searchState.target) { return ( void; -}) { - return ( - - We are also planning a Backlinks API so self-hosted apps can use - OpenSEO's backlinks data directly. Until then,{" "} - . - - } - buttonLabel="Confirm DataForSEO Access" - externalUrl="https://app.dataforseo.com/api-access-subscriptions" - externalLabel="Open DataForSEO Backlinks" - errorMessage={errorMessage} - isRefetching={isRefetching} - onRetry={onRetry} - /> - ); -} export function BacklinksLoadingState() { return ( @@ -90,16 +59,3 @@ export function BacklinksErrorState({ ); } - -function InlineManagedOpenSeoLink() { - return ( - - use managed OpenSEO - - ); -} diff --git a/src/client/features/backlinks/useBacklinksPageData.ts b/src/client/features/backlinks/useBacklinksPageData.ts index 23a384a..9548033 100644 --- a/src/client/features/backlinks/useBacklinksPageData.ts +++ b/src/client/features/backlinks/useBacklinksPageData.ts @@ -1,10 +1,9 @@ -import { useEffect, useMemo } from "react"; +import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import type { BacklinksPageProps, BacklinksSearchState, } from "./backlinksPageTypes"; -import { useAccessGate } from "@/client/features/access-gate/useAccessGate"; import { getErrorCode, getStandardErrorMessage, @@ -15,7 +14,6 @@ import { getBacklinksRows, getBacklinksTopPages, } from "@/serverFunctions/backlinks"; -import { getBacklinksAccessSetupStatus } from "@/serverFunctions/backlinksAccess"; import { BACKLINKS_DEFAULT_SORT, backlinksRowsSortFieldSchema, @@ -76,13 +74,6 @@ export function useBacklinksPageData({ searchState, filters, }: UseBacklinksPageDataArgs) { - const accessGate = useAccessGate({ - queryKey: ["backlinksAccessStatus", projectId], - queryFn: () => getBacklinksAccessSetupStatus({ data: { projectId } }), - statusErrorFallback: "Could not load Backlinks setup status.", - }); - const backlinksEnabled = accessGate.enabled; - const retryAccessGate = accessGate.onRetry; const searchCardInitialValues = useMemo( () => ({ target: searchState.target, @@ -93,7 +84,7 @@ export function useBacklinksPageData({ const { target, scope, tab, page, pageSize, sort, order, view } = searchState; const rowsMode = view === "all" ? "as_is" : "one_per_domain"; - const targetReady = backlinksEnabled && Boolean(target); + const targetReady = Boolean(target); const baseQueryKeyParts = [projectId, scope, target] as const; const pageInputBase = { projectId, target, scope, page, pageSize }; @@ -209,8 +200,6 @@ export function useBacklinksPageData({ overviewQuery.error, "Could not load backlinks data.", ); - const backlinksDisabledByError = - getErrorCode(overviewQuery.error) === "BACKLINKS_NOT_ENABLED"; const activeTabQuery = tab === "backlinks" ? rowsQuery @@ -221,28 +210,10 @@ export function useBacklinksPageData({ activeTabQuery.error, "Could not load this tab.", ); - const backlinksDisabledByTabError = - getErrorCode(activeTabQuery.error) === "BACKLINKS_NOT_ENABLED"; - - useEffect(() => { - if ( - (backlinksDisabledByError || backlinksDisabledByTabError) && - backlinksEnabled - ) { - retryAccessGate(); - } - }, [ - backlinksDisabledByError, - backlinksDisabledByTabError, - backlinksEnabled, - retryAccessGate, - ]); return { - accessGate, activeTabErrorMessage, activeTabQuery, - backlinksDisabledByError, overviewErrorMessage, overviewQuery, referringDomainsQuery, diff --git a/src/client/features/sam/SamChat.tsx b/src/client/features/sam/SamChat.tsx index e996292..cbdc993 100644 --- a/src/client/features/sam/SamChat.tsx +++ b/src/client/features/sam/SamChat.tsx @@ -7,7 +7,6 @@ import { invalidateSamSessions, samSessionsQueryOptions, } from "@/client/features/sam/samQueries"; -import { AccessGateLoadingState } from "@/client/features/access-gate/AccessGate"; import { useSamAccess } from "./useSamAccess"; import { SamSetupGate } from "./SamSetupGate"; import { SamConversation } from "./SamConversation"; @@ -57,22 +56,19 @@ export function SamChat({ goToSession(firstSessionId); }, [activeSessionId, firstSessionId, goToSession]); - // Gate the whole page until OPENROUTER_API_KEY is configured — SAM cannot - // answer a single turn without it, so surface setup instructions instead of - // letting a chat fail mid-stream. - if (access.isLoading || !access.enabled) { + // SAM cannot answer a turn without OPENROUTER_API_KEY, so surface setup + // instructions instead of letting a chat fail mid-stream. Only shown once the + // check confirms the key is missing (self-hosted) — never as a blocking + // skeleton while the check is in flight. + if (access.showSetupGate) { return (
- {access.isLoading ? ( - - ) : ( - - )} +
); diff --git a/src/client/features/sam/SamSetupGate.tsx b/src/client/features/sam/SamSetupGate.tsx index 588973f..ab73f16 100644 --- a/src/client/features/sam/SamSetupGate.tsx +++ b/src/client/features/sam/SamSetupGate.tsx @@ -1,5 +1,5 @@ import { Link } from "@tanstack/react-router"; -import { AccessGate } from "@/client/features/access-gate/AccessGate"; +import { ShieldAlert, Wrench } from "lucide-react"; export function SamSetupGate({ errorMessage, @@ -11,33 +11,58 @@ export function SamSetupGate({ onRetry: () => void; }) { return ( - - SAM, OpenSEO's in-app AI agent, needs an OpenRouter API key. Create a - key on OpenRouter, set it as the OPENROUTER_API_KEY{" "} - environment variable, restart OpenSEO, then confirm here. - - } - helperText={ - <> - Step-by-step instructions for every deployment are in the{" "} - +
+
+
+ +
+
+

Enable AI Features

+
+ SAM, OpenSEO's in-app AI agent, needs an OpenRouter API key. + Create a key on OpenRouter, set it as the{" "} + OPENROUTER_API_KEY environment variable, restart + OpenSEO, then confirm here. +
+
+ Step-by-step instructions for every deployment are in the{" "} + + OpenRouter API key setup guide + + . +
+
+
+ +
+ + + Open OpenRouter Keys + +
+ + {errorMessage ? ( +
+ + {errorMessage} +
+ ) : null} +
+ ); } diff --git a/src/client/features/sam/useSamAccess.ts b/src/client/features/sam/useSamAccess.ts index 0bfee49..8925ddd 100644 --- a/src/client/features/sam/useSamAccess.ts +++ b/src/client/features/sam/useSamAccess.ts @@ -1,10 +1,60 @@ -import { useAccessGate } from "@/client/features/access-gate/useAccessGate"; +import { useCallback } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { getStandardErrorMessage } from "@/client/lib/error-messages"; +import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { getSamAccessSetupStatus } from "@/serverFunctions/samAccess"; -export function useSamAccess(projectId: string) { - return useAccessGate({ +type SamAccess = { + // Only true once the setup check has resolved to "no access". It stays false + // while the check is in flight, so the chat renders immediately instead of + // blocking behind a skeleton — the gate only replaces it if we confirm the + // OpenRouter key is missing. + showSetupGate: boolean; + errorMessage: string | null; + isRefetching: boolean; + onRetry: () => void; +}; + +export function useSamAccess(projectId: string): SamAccess { + // Hosted deployments always have OPENROUTER_API_KEY provisioned (the server + // function short-circuits to enabled), so skip the round-trip entirely. + const isHosted = isHostedClientAuthMode(); + + const { data, error, isRefetching, refetch } = useQuery({ queryKey: ["samAccessStatus", projectId], queryFn: () => getSamAccessSetupStatus({ data: { projectId } }), - statusErrorFallback: "Could not load AI agent setup status.", + enabled: !isHosted, + refetchOnWindowFocus: false, + staleTime: 60 * 1000, }); + + const onRetry = useCallback(() => { + void refetch(); + }, [refetch]); + + if (isHosted) { + return { + showSetupGate: false, + errorMessage: null, + isRefetching: false, + onRetry, + }; + } + + // Optimistic: only gate once the check has actually resolved (success or + // error) and it says the key isn't there. + const resolved = data !== undefined || error != null; + return { + showSetupGate: resolved && !(data?.enabled ?? false), + errorMessage: + data?.errorMessage ?? + (error + ? getStandardErrorMessage( + error, + "Could not load AI agent setup status.", + ) + : null), + isRefetching, + onRetry, + }; } diff --git a/src/client/lib/error-messages.ts b/src/client/lib/error-messages.ts index d62041a..2b6a8dd 100644 --- a/src/client/lib/error-messages.ts +++ b/src/client/lib/error-messages.ts @@ -18,12 +18,8 @@ const STANDARD_MESSAGES: Record = { "You already have an audit running. Wait for it to finish or delete it before starting another.", VALIDATION_ERROR: "Please check your input and try again.", CRAWL_TARGET_BLOCKED: "This crawl target is blocked by security policy.", - BACKLINKS_NOT_ENABLED: - "Backlinks is not enabled for the connected DataForSEO account yet.", BACKLINKS_BILLING_ISSUE: "The connected DataForSEO account has a billing or balance issue.", - AI_SEARCH_NOT_ENABLED: - "AI Optimization is not enabled for the connected DataForSEO account yet.", AI_SEARCH_BILLING_ISSUE: "The connected DataForSEO account has a billing or balance issue.", DATAFORSEO_AUTH_FAILED: diff --git a/src/server/features/ai-search/services/brandLookup.ts b/src/server/features/ai-search/services/brandLookup.ts index 260397f..4964dd2 100644 --- a/src/server/features/ai-search/services/brandLookup.ts +++ b/src/server/features/ai-search/services/brandLookup.ts @@ -305,7 +305,6 @@ function rethrowIfBlockingAiSearchError( result.status === "rejected" && result.reason instanceof AppError && (result.reason.code === "INSUFFICIENT_CREDITS" || - result.reason.code === "AI_SEARCH_NOT_ENABLED" || result.reason.code === "AI_SEARCH_BILLING_ISSUE") ) { throw result.reason; diff --git a/src/server/features/ai-search/services/promptExplorer.ts b/src/server/features/ai-search/services/promptExplorer.ts index 39404e2..6312f9e 100644 --- a/src/server/features/ai-search/services/promptExplorer.ts +++ b/src/server/features/ai-search/services/promptExplorer.ts @@ -286,7 +286,6 @@ function mapErrorToResult( if ( reason instanceof AppError && (reason.code === "INSUFFICIENT_CREDITS" || - reason.code === "AI_SEARCH_NOT_ENABLED" || reason.code === "AI_SEARCH_BILLING_ISSUE") ) { // These account-level failures apply to every model, so surface one clear diff --git a/src/server/lib/dataforseo/ai.ts b/src/server/lib/dataforseo/ai.ts index 0401daa..657024f 100644 --- a/src/server/lib/dataforseo/ai.ts +++ b/src/server/lib/dataforseo/ai.ts @@ -25,7 +25,7 @@ import { type LlmResponseResult, type LlmTopPagesItem, } from "@/server/lib/dataforseoLlmSchemas"; -import { createDataforseoAccessClassifier } from "@/server/lib/dataforseoAccessClassification"; +import { createDataforseoBillingClassifier } from "@/server/lib/dataforseoBillingClassification"; import { AppError } from "@/server/lib/errors"; import { aiOptimizationApi } from "@/server/lib/dataforseo/core"; import { @@ -42,11 +42,8 @@ export const CHATGPT_LANGUAGE_CODE = "en"; export type LlmPlatform = "chat_gpt" | "google"; -const classifyAiSearchError = createDataforseoAccessClassifier({ +const classifyAiSearchError = createDataforseoBillingClassifier({ pathPrefix: "/ai_optimization/", - notEnabledCode: "AI_SEARCH_NOT_ENABLED", - notEnabledMessage: - "AI Optimization is not enabled for the connected DataForSEO account", billingIssueCode: "AI_SEARCH_BILLING_ISSUE", billingIssueMessage: "The connected DataForSEO account has a billing or balance issue", diff --git a/src/server/lib/dataforseo/backlinks.test.ts b/src/server/lib/dataforseo/backlinks.test.ts index 585ffbd..1c7294d 100644 --- a/src/server/lib/dataforseo/backlinks.test.ts +++ b/src/server/lib/dataforseo/backlinks.test.ts @@ -9,10 +9,10 @@ const { classifyBacklinksError } = vi.hoisted(() => ({ classifyBacklinksError: vi.fn(), })); -// The classifier is built inside backlinks.ts via createDataforseoAccessClassifier; +// The classifier is built inside backlinks.ts via createDataforseoBillingClassifier; // returning our hoisted mock lets the test drive classification. -vi.mock("@/server/lib/dataforseoAccessClassification", () => ({ - createDataforseoAccessClassifier: () => classifyBacklinksError, +vi.mock("@/server/lib/dataforseoBillingClassification", () => ({ + createDataforseoBillingClassifier: () => classifyBacklinksError, })); import { @@ -107,18 +107,18 @@ describe("fetchBacklinksSummary", () => { vi.mocked(fetch).mockResolvedValue( new Response( JSON.stringify({ - status_code: 40204, - status_message: "Backlinks subscription required", + status_code: 40200, + status_message: "Account balance is too low", tasks: [], }), { status: 200, headers: { "Content-Type": "application/json" } }, ), ); classifyBacklinksError.mockImplementation((status: number | undefined) => { - if (status === 40204) { + if (status === 40200) { return new AppError( - "BACKLINKS_NOT_ENABLED", - "Backlinks is not enabled", + "BACKLINKS_BILLING_ISSUE", + "The connected DataForSEO account has a billing or balance issue", ); } return null; @@ -126,11 +126,11 @@ describe("fetchBacklinksSummary", () => { await expect( fetchBacklinksSummary({ target: "example.com" }), - ).rejects.toMatchObject({ code: "BACKLINKS_NOT_ENABLED" }); + ).rejects.toMatchObject({ code: "BACKLINKS_BILLING_ISSUE" }); expect(classifyBacklinksError).toHaveBeenCalledWith( - 40204, - expect.stringContaining("Backlinks subscription required"), + 40200, + expect.stringContaining("Account balance is too low"), "/v3/backlinks/summary/live", ); }); diff --git a/src/server/lib/dataforseo/backlinks.ts b/src/server/lib/dataforseo/backlinks.ts index ea4491b..c4b30d6 100644 --- a/src/server/lib/dataforseo/backlinks.ts +++ b/src/server/lib/dataforseo/backlinks.ts @@ -10,7 +10,7 @@ import { normalizeBacklinksSpamFilterOptions, type BacklinksSpamFilterOptions, } from "@/types/schemas/backlinks"; -import { createDataforseoAccessClassifier } from "@/server/lib/dataforseoAccessClassification"; +import { createDataforseoBillingClassifier } from "@/server/lib/dataforseoBillingClassification"; import { AppError } from "@/server/lib/errors"; import { backlinksApi } from "@/server/lib/dataforseo/core"; import { @@ -41,11 +41,8 @@ type BacklinksTimeseriesRequest = { dateTo: string; }; -const classifyBacklinksError = createDataforseoAccessClassifier({ +const classifyBacklinksError = createDataforseoBillingClassifier({ pathPrefix: "/backlinks/", - notEnabledCode: "BACKLINKS_NOT_ENABLED", - notEnabledMessage: - "Backlinks is not enabled for the connected DataForSEO account", billingIssueCode: "BACKLINKS_BILLING_ISSUE", billingIssueMessage: "The connected DataForSEO account has a billing or balance issue", diff --git a/src/server/lib/dataforseo/core.ts b/src/server/lib/dataforseo/core.ts index 221016a..b293704 100644 --- a/src/server/lib/dataforseo/core.ts +++ b/src/server/lib/dataforseo/core.ts @@ -22,9 +22,9 @@ const DATAFORSEO_RETRY_BACKOFF_MS = 250; /** * Translates a DataForSEO HTTP/task failure into a product-specific AppError - * (e.g. "backlinks not enabled", "billing issue"). Returns null when the - * failure isn't one this classifier recognises, so the caller can fall back to - * a generic error. See {@link createDataforseoAccessClassifier}. + * (e.g. "billing issue"). Returns null when the failure isn't one this + * classifier recognises, so the caller can fall back to a generic error. See + * {@link createDataforseoBillingClassifier}. */ export type DataforseoErrorClassifier = ( status: number | undefined, diff --git a/src/server/lib/dataforseo/envelope.test.ts b/src/server/lib/dataforseo/envelope.test.ts index b95998a..d9b2f68 100644 --- a/src/server/lib/dataforseo/envelope.test.ts +++ b/src/server/lib/dataforseo/envelope.test.ts @@ -83,10 +83,12 @@ describe("assertOk", () => { }); it("uses the classifier for non-charged (no-cost) failures", () => { - const classify = vi.fn(() => new AppError("BACKLINKS_NOT_ENABLED", "nope")); + const classify = vi.fn( + () => new AppError("BACKLINKS_BILLING_ISSUE", "nope"), + ); const task = { - status_code: 40204, - status_message: "subscription required", + status_code: 40200, + status_message: "balance is too low", }; expect(() => assertOk( @@ -95,16 +97,16 @@ describe("assertOk", () => { ), ).toThrow("nope"); expect(classify).toHaveBeenCalledWith( - 40204, - "subscription required", + 40200, + "balance is too low", "/v3/backlinks/summary/live", ); }); it.each([ - [40204, "BACKLINKS_NOT_ENABLED"], - [403, "BACKLINKS_NOT_ENABLED"], [40200, "BACKLINKS_BILLING_ISSUE"], + [40210, "BACKLINKS_BILLING_ISSUE"], + [402, "BACKLINKS_BILLING_ISSUE"], ] as const)( "uses the classifier for account failure %s before charging billed task metadata", (status, code) => { @@ -113,7 +115,7 @@ describe("assertOk", () => { ); const task = { status_code: status, - status_message: "Backlinks subscription required", + status_message: "Account balance is too low", path: ["v3", "backlinks", "summary", "live"], cost: 0.05, result_count: 0, @@ -128,7 +130,7 @@ describe("assertOk", () => { } expect(classify).toHaveBeenCalledWith( status, - "Backlinks subscription required", + "Account balance is too low", "/v3/backlinks/summary/live", ); }, diff --git a/src/server/lib/dataforseoAccountState.ts b/src/server/lib/dataforseoAccountState.ts deleted file mode 100644 index 699ae49..0000000 --- a/src/server/lib/dataforseoAccountState.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { z } from "zod"; -import { AppError } from "@/server/lib/errors"; -import { getRequiredEnvValue } from "@/server/lib/runtime-env"; - -const API_BASE = "https://api.dataforseo.com"; - -const userDataResponseSchema = z - .object({ - status_code: z.number().optional(), - tasks: z - .array( - z - .object({ - status_code: z.number().optional(), - result: z - .array( - z - .object({ - backlinks_subscription_expiry_date: z - .string() - .nullable() - .optional(), - llm_mentions_subscription_expiry_date: z - .string() - .nullable() - .optional(), - }) - .passthrough(), - ) - .nullable() - .optional(), - }) - .passthrough(), - ) - .optional(), - }) - .passthrough(); - -type DataforseoAccountState = { - backlinksSubscriptionExpiryDate: string | null; - llmMentionsSubscriptionExpiryDate: string | null; -}; - -export function hasActiveDataforseoSubscription( - expiryDate: string | null, -): boolean { - if (!expiryDate) return false; - - const expiryTime = Date.parse(expiryDate); - return Number.isFinite(expiryTime) && expiryTime > Date.now(); -} - -export async function fetchDataforseoAccountState(): Promise { - const apiKey = await getRequiredEnvValue("DATAFORSEO_API_KEY"); - const response = await fetch(`${API_BASE}/v3/appendix/user_data`, { - method: "GET", - headers: { Authorization: `Basic ${apiKey}` }, - }); - - if (!response.ok) { - // 401/403 here means the API key itself is invalid or missing — surface a - // clear, actionable message instead of a generic "unexpected error". - if (response.status === 401 || response.status === 403) { - throw new AppError( - "DATAFORSEO_AUTH_FAILED", - `DataForSEO HTTP ${response.status} on /v3/appendix/user_data`, - ); - } - throw new AppError( - "INTERNAL_ERROR", - `DataForSEO HTTP ${response.status} on /v3/appendix/user_data`, - ); - } - - const raw = await response.json(); - const parsed = userDataResponseSchema.safeParse(raw); - if (!parsed.success || parsed.data.status_code !== 20000) return null; - - const task = parsed.data.tasks?.[0]; - if (!task || task.status_code !== 20000) return null; - - const result = task.result?.[0]; - if (!result) return null; - - return { - backlinksSubscriptionExpiryDate: - result.backlinks_subscription_expiry_date ?? null, - llmMentionsSubscriptionExpiryDate: - result.llm_mentions_subscription_expiry_date ?? null, - }; -} diff --git a/src/server/lib/dataforseoAccessClassification.test.ts b/src/server/lib/dataforseoBillingClassification.test.ts similarity index 55% rename from src/server/lib/dataforseoAccessClassification.test.ts rename to src/server/lib/dataforseoBillingClassification.test.ts index 9f0f972..77ca572 100644 --- a/src/server/lib/dataforseoAccessClassification.test.ts +++ b/src/server/lib/dataforseoBillingClassification.test.ts @@ -1,29 +1,19 @@ import { describe, expect, it } from "vitest"; -import { createDataforseoAccessClassifier } from "@/server/lib/dataforseoAccessClassification"; +import { createDataforseoBillingClassifier } from "@/server/lib/dataforseoBillingClassification"; -const classify = createDataforseoAccessClassifier({ +const classify = createDataforseoBillingClassifier({ pathPrefix: "/backlinks/", - notEnabledCode: "BACKLINKS_NOT_ENABLED", - notEnabledMessage: "not enabled", billingIssueCode: "BACKLINKS_BILLING_ISSUE", billingIssueMessage: "billing issue", }); -describe("createDataforseoAccessClassifier", () => { +describe("createDataforseoBillingClassifier", () => { it("returns null when the path is outside the configured prefix", () => { expect(classify(402, "payment required", "/v3/serp/google/live")).toBe( null, ); }); - it.each([40204, 403])( - "translates status %s into the configured error code when inside the path prefix", - (status) => { - const err = classify(status, "", "/v3/backlinks/summary/live"); - expect(err?.code).toBe("BACKLINKS_NOT_ENABLED"); - }, - ); - it.each([40200, 40210, 402])( "translates billing status %s into the configured billing error code", (status) => { @@ -32,16 +22,6 @@ describe("createDataforseoAccessClassifier", () => { }, ); - it.each([ - "subscription required", - "plans and subscriptions", - "access denied", - "forbidden", - ])("translates signal %s into the configured error code", (message) => { - const err = classify(undefined, message, "/v3/backlinks/summary/live"); - expect(err?.code).toBe("BACKLINKS_NOT_ENABLED"); - }); - it.each([ "insufficient funds", "payment required", @@ -56,16 +36,25 @@ describe("createDataforseoAccessClassifier", () => { }, ); + it("no longer classifies feature-access signals now that the add-ons are bundled", () => { + expect( + classify(40204, "subscription required", "/v3/backlinks/summary/live"), + ).toBe(null); + expect(classify(403, "access denied", "/v3/backlinks/summary/live")).toBe( + null, + ); + }); + it("returns null when neither status nor text matches", () => { expect(classify(500, "boom", "/v3/backlinks/summary/live")).toBe(null); }); - it("matches signals case-insensitively", () => { + it("matches billing signals case-insensitively", () => { const err = classify( undefined, - "SUBSCRIPTION required", + "INSUFFICIENT funds", "/v3/backlinks/summary/live", ); - expect(err?.code).toBe("BACKLINKS_NOT_ENABLED"); + expect(err?.code).toBe("BACKLINKS_BILLING_ISSUE"); }); }); diff --git a/src/server/lib/dataforseoAccessClassification.ts b/src/server/lib/dataforseoBillingClassification.ts similarity index 54% rename from src/server/lib/dataforseoAccessClassification.ts rename to src/server/lib/dataforseoBillingClassification.ts index 477c55d..82037f9 100644 --- a/src/server/lib/dataforseoAccessClassification.ts +++ b/src/server/lib/dataforseoBillingClassification.ts @@ -1,20 +1,6 @@ import { AppError } from "@/server/lib/errors"; import type { ErrorCode } from "@/shared/error-codes"; -const ACCESS_SIGNALS = [ - "not available", - "not enabled", - "not allowed", - "access denied", - "forbidden", - "insufficient", - "subscription", - "upgrade", - "plan", - "activate your subscription", - "plans and subscriptions", -]; - const BILLING_SIGNALS = [ "insufficient funds", "balance is too low", @@ -25,22 +11,25 @@ const BILLING_SIGNALS = [ "recharged", ]; -const ACCESS_STATUS_CODES = new Set([40204, 403]); const BILLING_STATUS_CODES = new Set([40200, 40210, 402]); -type DataforseoAccessClassifier = ( +type DataforseoBillingClassifier = ( status: number | undefined, details: string, path: string, ) => AppError | null; -export function createDataforseoAccessClassifier(config: { +/** + * Maps DataForSEO balance/payment failures for a given API section to a typed + * billing error. Feature-enablement is no longer classified: Backlinks and AI + * Optimization are included in every DataForSEO account, so the only remaining + * account-level failure is a depleted balance. + */ +export function createDataforseoBillingClassifier(config: { pathPrefix: string; - notEnabledCode: ErrorCode; - notEnabledMessage: string; billingIssueCode: ErrorCode; billingIssueMessage: string; -}): DataforseoAccessClassifier { +}): DataforseoBillingClassifier { return (status, details, path) => { if (!path.includes(config.pathPrefix)) return null; @@ -54,13 +43,6 @@ export function createDataforseoAccessClassifier(config: { return new AppError(config.billingIssueCode, config.billingIssueMessage); } - const matchesAccessStatus = - status != null && ACCESS_STATUS_CODES.has(status); - const matchesAccessText = ACCESS_SIGNALS.some((signal) => - text.includes(signal), - ); - if (!matchesAccessStatus && !matchesAccessText) return null; - - return new AppError(config.notEnabledCode, config.notEnabledMessage); + return null; }; } diff --git a/src/serverFunctions/aiSearchAccess.ts b/src/serverFunctions/aiSearchAccess.ts deleted file mode 100644 index 22e66a1..0000000 --- a/src/serverFunctions/aiSearchAccess.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { createServerFn } from "@tanstack/react-start"; -import { - fetchDataforseoAccountState, - hasActiveDataforseoSubscription, -} from "@/server/lib/dataforseoAccountState"; -import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; -import { requireProjectContext } from "@/serverFunctions/middleware"; -import { aiSearchProjectSchema } from "@/types/schemas/ai-search"; - -const AI_SEARCH_NOT_ENABLED_MESSAGE = - "AI Optimization is not enabled for the connected DataForSEO account yet. Turn it on in DataForSEO, then confirm here."; - -type AiSearchAccessStatus = { - enabled: boolean; - errorMessage: string | null; -}; - -export const getAiSearchAccessSetupStatus = createServerFn({ method: "GET" }) - .middleware(requireProjectContext) - .validator(aiSearchProjectSchema) - .handler(async (): Promise => { - if (await isHostedServerAuthMode()) { - return { enabled: true, errorMessage: null }; - } - - const state = await fetchDataforseoAccountState(); - const enabled = hasActiveDataforseoSubscription( - state?.llmMentionsSubscriptionExpiryDate ?? null, - ); - return { - enabled, - errorMessage: enabled ? null : AI_SEARCH_NOT_ENABLED_MESSAGE, - }; - }); diff --git a/src/serverFunctions/backlinksAccess.ts b/src/serverFunctions/backlinksAccess.ts deleted file mode 100644 index 80038d6..0000000 --- a/src/serverFunctions/backlinksAccess.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { createServerFn } from "@tanstack/react-start"; -import { - fetchDataforseoAccountState, - hasActiveDataforseoSubscription, -} from "@/server/lib/dataforseoAccountState"; -import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; -import { requireProjectContext } from "@/serverFunctions/middleware"; -import { backlinksProjectSchema } from "@/types/schemas/backlinks"; - -const BACKLINKS_NOT_ENABLED_MESSAGE = - "Backlinks is not enabled for the connected DataForSEO account yet. Turn it on in DataForSEO, then confirm here."; - -type BacklinksAccessStatus = { - enabled: boolean; - errorMessage: string | null; -}; - -export const getBacklinksAccessSetupStatus = createServerFn({ method: "GET" }) - .middleware(requireProjectContext) - .validator(backlinksProjectSchema) - .handler(async (): Promise => { - if (await isHostedServerAuthMode()) { - return { enabled: true, errorMessage: null }; - } - - const state = await fetchDataforseoAccountState(); - const enabled = hasActiveDataforseoSubscription( - state?.backlinksSubscriptionExpiryDate ?? null, - ); - return { - enabled, - errorMessage: enabled ? null : BACKLINKS_NOT_ENABLED_MESSAGE, - }; - }); diff --git a/src/shared/error-codes.test.ts b/src/shared/error-codes.test.ts index b724036..088b577 100644 --- a/src/shared/error-codes.test.ts +++ b/src/shared/error-codes.test.ts @@ -17,9 +17,9 @@ describe("shouldCaptureAppErrorCode", () => { it("captures unexpected errors and unknown failures", () => { expect(shouldCaptureAppErrorCode("INTERNAL_ERROR")).toBe(true); expect(shouldCaptureAppErrorCode(undefined)).toBe(true); - // On cloud the shared DataForSEO account has these add-ons, so these firing - // signals a real platform problem — keep them reportable, don't suppress. - expect(shouldCaptureAppErrorCode("BACKLINKS_NOT_ENABLED")).toBe(true); - expect(shouldCaptureAppErrorCode("AI_SEARCH_NOT_ENABLED")).toBe(true); + // A depleted DataForSEO balance is a real platform problem on cloud — keep + // the billing codes reportable, don't suppress them. + expect(shouldCaptureAppErrorCode("BACKLINKS_BILLING_ISSUE")).toBe(true); + expect(shouldCaptureAppErrorCode("AI_SEARCH_BILLING_ISSUE")).toBe(true); }); }); diff --git a/src/shared/error-codes.ts b/src/shared/error-codes.ts index fb36d19..be07481 100644 --- a/src/shared/error-codes.ts +++ b/src/shared/error-codes.ts @@ -12,9 +12,7 @@ const ERROR_CODES = [ "AUDIT_ALREADY_RUNNING", "VALIDATION_ERROR", "CRAWL_TARGET_BLOCKED", - "BACKLINKS_NOT_ENABLED", "BACKLINKS_BILLING_ISSUE", - "AI_SEARCH_NOT_ENABLED", "AI_SEARCH_BILLING_ISSUE", "DATAFORSEO_AUTH_FAILED", "RATE_LIMITED", diff --git a/src/types/schemas/ai-search.ts b/src/types/schemas/ai-search.ts index 09e27e8..f8228d4 100644 --- a/src/types/schemas/ai-search.ts +++ b/src/types/schemas/ai-search.ts @@ -7,14 +7,6 @@ import { z } from "zod"; * by server functions, services, R2 cache validation, and the client UI. */ -// --------------------------------------------------------------------------- -// AI Search access setup (self-hosted mode only) -// --------------------------------------------------------------------------- - -export const aiSearchProjectSchema = z.object({ - projectId: z.string().min(1), -}); - // --------------------------------------------------------------------------- // Brand Lookup // --------------------------------------------------------------------------- diff --git a/src/types/schemas/backlinks.ts b/src/types/schemas/backlinks.ts index 477cc6e..98e4e29 100644 --- a/src/types/schemas/backlinks.ts +++ b/src/types/schemas/backlinks.ts @@ -36,10 +36,6 @@ export const backlinksLookupSchema = z.object({ scope: backlinksTargetScopeSchema.optional(), }); -export const backlinksProjectSchema = z.object({ - projectId: z.string().min(1), -}); - export const backlinksOverviewInputSchema = backlinksLookupSchema.extend({ projectId: z.string().min(1), });