Remove backlinks + LLM-mentions access gates

This commit is contained in:
Ben Senescu 2026-07-05 23:25:33 -04:00 committed by GitHub
parent 3c7e704b4a
commit c3caf2009f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 200 additions and 660 deletions

View File

@ -1,83 +0,0 @@
import type { ReactNode } from "react";
import { ShieldAlert, Wrench } from "lucide-react";
export function AccessGateLoadingState() {
return (
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-4">
<div className="skeleton h-6 w-48" />
<div className="skeleton h-4 w-full" />
<div className="skeleton h-4 w-4/5" />
<div className="skeleton h-10 w-48" />
</div>
</div>
);
}
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 (
<section>
<div className="rounded-2xl border border-base-300 bg-base-100 p-6 md:p-7 space-y-5">
<div className="flex items-start gap-3">
<div className="rounded-xl bg-warning/15 p-2.5 text-warning shrink-0">
<Wrench className="size-5" />
</div>
<div className="max-w-3xl space-y-1.5">
<h2 className="text-xl font-semibold">{title}</h2>
<div className="text-sm text-base-content/68">{bodyText}</div>
{helperText ? (
<div className="text-xs text-base-content/50">{helperText}</div>
) : null}
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
className="btn btn-primary"
onClick={onRetry}
disabled={isRefetching}
>
{isRefetching ? refetchingLabel : buttonLabel}
</button>
<a
className="btn"
href={externalUrl}
target="_blank"
rel="noreferrer"
>
{externalLabel}
</a>
</div>
{errorMessage ? (
<div className="alert alert-warning">
<ShieldAlert className="size-4 shrink-0" />
<span>{errorMessage}</span>
</div>
) : null}
</div>
</section>
);
}

View File

@ -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<AccessGateStatus>;
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,
};
}

View File

@ -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 (
<div className="px-4 py-4 pb-24 overflow-auto md:px-6 md:py-6 md:pb-8">
<div className="mx-auto max-w-7xl space-y-4">
@ -212,15 +208,7 @@ function BrandLookupPageInner({
</p>
</div>
{access.isLoading ? (
<AccessGateLoadingState />
) : !access.enabled ? (
<AiSearchSetupGate
errorMessage={access.errorMessage ?? access.statusErrorMessage}
isRefetching={access.isRefetching}
onRetry={access.onRetry}
/>
) : planGate.isFreePlan ? (
{planGate.isFreePlan ? (
<AiSearchPaidPlanGate
feature="Brand Lookup"
description="See how ChatGPT and Google AI Overview cite any brand or domain — total mentions, sample prompts where it appears, and the pages cited alongside it."

View File

@ -19,9 +19,6 @@ import { PromptExplorerResults } from "@/client/features/ai-search/components/Pr
import { PromptExplorerLoadingState } from "@/client/features/ai-search/components/PromptExplorerLoadingState";
import { PromptExplorerHistorySection } from "@/client/features/ai-search/components/PromptExplorerHistorySection";
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 { usePromptExplorerSearchHistory } from "@/client/hooks/usePromptExplorerSearchHistory";
import {
PROMPT_EXPLORER_MAX_PROMPT_LENGTH,
@ -77,7 +74,6 @@ function PromptExplorerPageInner({
}: Props & { planGate: HostedPlanGateState }) {
const [form, setForm] = useState<PromptExplorerFormValues>(urlState);
const [validationError, setValidationError] = useState<string | null>(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 (
<div className="px-4 py-4 pb-24 overflow-auto md:px-6 md:py-6 md:pb-8">
<div className="mx-auto max-w-7xl space-y-4">
@ -212,15 +206,7 @@ function PromptExplorerPageInner({
</p>
</div>
{access.isLoading ? (
<AccessGateLoadingState />
) : !access.enabled ? (
<AiSearchSetupGate
errorMessage={access.errorMessage ?? access.statusErrorMessage}
isRefetching={access.isRefetching}
onRetry={access.onRetry}
/>
) : planGate.isFreePlan ? (
{planGate.isFreePlan ? (
<AiSearchPaidPlanGate
feature="Prompt Explorer"
description="Ask one prompt across ChatGPT, Claude, Gemini, and Perplexity at the same time and compare their answers — including which sources each model cites."

View File

@ -1,43 +0,0 @@
import { AccessGate } from "@/client/features/access-gate/AccessGate";
export function AiSearchSetupGate({
errorMessage,
isRefetching,
onRetry,
}: {
errorMessage: string | null;
isRefetching: boolean;
onRetry: () => void;
}) {
return (
<AccessGate
title="Enable AI Optimization"
bodyText="AI Optimization is not enabled for your DataForSEO account yet. You can enable it in DataForSEO, or use managed OpenSEO for long-term LLM Mentions access at $10/month."
helperText={
<>
We are also planning an API so self-hosted apps can use OpenSEO's LLM
Mentions data directly. Until then, <InlineManagedOpenSeoLink />.
</>
}
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 (
<a
className="underline underline-offset-2 hover:text-base-content/70"
href="https://openseo.so/?utm_source=self_hosted_app&utm_medium=access_gate&utm_campaign=llm_mentions"
target="_blank"
rel="noreferrer"
>
use managed OpenSEO
</a>
);
}

View File

@ -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.",
});
}

View File

@ -101,10 +101,8 @@ export function BacklinksPage({
});
const {
accessGate,
activeTabErrorMessage,
activeTabQuery,
backlinksDisabledByError,
overviewErrorMessage,
overviewQuery,
referringDomainsQuery,
@ -192,9 +190,6 @@ export function BacklinksPage({
</p>
</div>
{!accessGate.isLoading &&
accessGate.enabled &&
!backlinksDisabledByError ? (
<BacklinksSearchCard
errorMessage={overviewErrorMessage}
initialValues={searchCardInitialValues}
@ -208,12 +203,9 @@ export function BacklinksPage({
addSearch({ target: values.target, scope: values.scope });
}}
/>
) : null}
<BacklinksBody
projectId={projectId}
accessGate={accessGate}
backlinksDisabledByError={backlinksDisabledByError}
history={history}
historyLoaded={historyLoaded}
overviewData={overviewQuery.data}

View File

@ -5,7 +5,6 @@ import { BacklinksResultsCard } from "./BacklinksPageSections";
import {
BacklinksErrorState,
BacklinksLoadingState,
BacklinksSetupGate,
} from "./BacklinksPageStates";
import { BacklinksHistorySection } from "./BacklinksHistorySection";
import type { BacklinksSearchHistoryItem } from "@/client/hooks/useBacklinksSearchHistory";
@ -17,8 +16,6 @@ import type {
BacklinksTabRows,
BacklinksTopPagesData,
} from "./backlinksPageTypes";
import type { UseAccessGateResult } from "@/client/features/access-gate/useAccessGate";
import { AccessGateLoadingState } from "@/client/features/access-gate/AccessGate";
import { buildSummaryStats } from "./backlinksPageUtils";
import type { BacklinksDomainExpansion } from "./useBacklinksDomainExpansion";
import type { BacklinksFiltersState } from "./useBacklinksFilters";
@ -29,8 +26,6 @@ import {
type BacklinksBodyProps = {
projectId: string;
accessGate: UseAccessGateResult;
backlinksDisabledByError: boolean;
history: BacklinksSearchHistoryItem[];
historyLoaded: boolean;
overviewData: BacklinksOverviewData | undefined;
@ -64,8 +59,6 @@ type BacklinksBodyProps = {
export function BacklinksBody({
projectId,
accessGate,
backlinksDisabledByError,
history,
historyLoaded,
overviewData,
@ -119,29 +112,6 @@ export function BacklinksBody({
/>
) : null;
if (accessGate.isLoading) {
return <AccessGateLoadingState />;
}
if (accessGate.statusErrorMessage) {
return (
<BacklinksErrorState
errorMessage={accessGate.statusErrorMessage}
onRetry={accessGate.onRetry}
/>
);
}
if (!accessGate.enabled || backlinksDisabledByError) {
return (
<BacklinksSetupGate
errorMessage={accessGate.errorMessage}
isRefetching={accessGate.isRefetching}
onRetry={accessGate.onRetry}
/>
);
}
if (!searchState.target) {
return (
<BacklinksHistorySection

View File

@ -1,35 +1,4 @@
import { ShieldAlert } from "lucide-react";
import { AccessGate } from "@/client/features/access-gate/AccessGate";
export function BacklinksSetupGate({
errorMessage,
isRefetching,
onRetry,
}: {
errorMessage: string | null;
isRefetching: boolean;
onRetry: () => void;
}) {
return (
<AccessGate
title="Enable Backlinks"
bodyText="Backlinks are not enabled for your DataForSEO account yet. You can enable them in DataForSEO, or use managed OpenSEO for long-term backlinks access at $10/month."
helperText={
<>
We are also planning a Backlinks API so self-hosted apps can use
OpenSEO's backlinks data directly. Until then,{" "}
<InlineManagedOpenSeoLink />.
</>
}
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({
</section>
);
}
function InlineManagedOpenSeoLink() {
return (
<a
className="underline underline-offset-2 hover:text-base-content/70"
href="https://openseo.so/?utm_source=self_hosted_app&utm_medium=access_gate&utm_campaign=backlinks"
target="_blank"
rel="noreferrer"
>
use managed OpenSEO
</a>
);
}

View File

@ -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,

View File

@ -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 (
<div className="overflow-auto px-4 py-4 md:px-6 md:py-6">
<div className="mx-auto max-w-3xl">
{access.isLoading ? (
<AccessGateLoadingState />
) : (
<SamSetupGate
errorMessage={access.errorMessage ?? access.statusErrorMessage}
errorMessage={access.errorMessage}
isRefetching={access.isRefetching}
onRetry={access.onRetry}
/>
)}
</div>
</div>
);

View File

@ -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,17 +11,21 @@ export function SamSetupGate({
onRetry: () => void;
}) {
return (
<AccessGate
title="Enable AI Features"
bodyText={
<>
SAM, OpenSEO's in-app AI agent, needs an OpenRouter API key. Create a
key on OpenRouter, set it as the <code>OPENROUTER_API_KEY</code>{" "}
environment variable, restart OpenSEO, then confirm here.
</>
}
helperText={
<>
<section>
<div className="rounded-2xl border border-base-300 bg-base-100 p-6 md:p-7 space-y-5">
<div className="flex items-start gap-3">
<div className="rounded-xl bg-warning/15 p-2.5 text-warning shrink-0">
<Wrench className="size-5" />
</div>
<div className="max-w-3xl space-y-1.5">
<h2 className="text-xl font-semibold">Enable AI Features</h2>
<div className="text-sm text-base-content/68">
SAM, OpenSEO's in-app AI agent, needs an OpenRouter API key.
Create a key on OpenRouter, set it as the{" "}
<code>OPENROUTER_API_KEY</code> environment variable, restart
OpenSEO, then confirm here.
</div>
<div className="text-xs text-base-content/50">
Step-by-step instructions for every deployment are in the{" "}
<Link
className="underline underline-offset-2 hover:text-base-content/70"
@ -30,14 +34,35 @@ export function SamSetupGate({
OpenRouter API key setup guide
</Link>
.
</>
}
buttonLabel="Confirm API Key"
externalUrl="https://openrouter.ai/settings/keys"
externalLabel="Open OpenRouter Keys"
errorMessage={errorMessage}
isRefetching={isRefetching}
onRetry={onRetry}
/>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
className="btn btn-primary"
onClick={onRetry}
disabled={isRefetching}
>
{isRefetching ? "Confirming..." : "Confirm API Key"}
</button>
<a
className="btn"
href="https://openrouter.ai/settings/keys"
target="_blank"
rel="noreferrer"
>
Open OpenRouter Keys
</a>
</div>
{errorMessage ? (
<div className="alert alert-warning">
<ShieldAlert className="size-4 shrink-0" />
<span>{errorMessage}</span>
</div>
) : null}
</div>
</section>
);
}

View File

@ -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,
};
}

View File

@ -18,12 +18,8 @@ const STANDARD_MESSAGES: Record<ErrorCode, string> = {
"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:

View File

@ -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;

View File

@ -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

View File

@ -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",

View File

@ -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",
);
});

View File

@ -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",

View File

@ -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,

View File

@ -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",
);
},

View File

@ -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<DataforseoAccountState | null> {
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,
};
}

View File

@ -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");
});
});

View File

@ -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;
};
}

View File

@ -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<AiSearchAccessStatus> => {
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,
};
});

View File

@ -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<BacklinksAccessStatus> => {
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,
};
});

View File

@ -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);
});
});

View File

@ -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",

View File

@ -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
// ---------------------------------------------------------------------------

View File

@ -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),
});