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 { BrandLookupHistorySection } from "@/client/features/ai-search/components/BrandLookupHistorySection";
import { AiSearchLoadingState } from "@/client/features/ai-search/components/AiSearchLoadingState"; import { AiSearchLoadingState } from "@/client/features/ai-search/components/AiSearchLoadingState";
import { AiSearchPaidPlanGate } from "@/client/features/ai-search/components/AiSearchPaidPlanGate"; 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 { useBrandLookupSearchHistory } from "@/client/hooks/useBrandLookupSearchHistory";
import { import {
BRAND_LOOKUP_MAX_INPUT_LENGTH, BRAND_LOOKUP_MAX_INPUT_LENGTH,
@ -80,8 +77,6 @@ function BrandLookupPageInner({
message: string; message: string;
} | null>(null); } | null>(null);
const access = useAiSearchAccess(projectId);
const trimmedInitialQuery = initialQuery.trim(); const trimmedInitialQuery = initialQuery.trim();
const hasActiveQuery = trimmedInitialQuery.length > 0; const hasActiveQuery = trimmedInitialQuery.length > 0;
// The URL `c` param is the source of truth for the active lookup; the local // The URL `c` param is the source of truth for the active lookup; the local
@ -101,7 +96,10 @@ function BrandLookupPageInner({
languageCode: "en", 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, staleTime: 5 * 60 * 1000,
retry: false, retry: false,
}); });
@ -200,8 +198,6 @@ function BrandLookupPageInner({
: null; : null;
const resultData = hasActiveQuery ? lookupQuery.data : undefined; const resultData = hasActiveQuery ? lookupQuery.data : undefined;
if (planGate.isLoading) return null;
return ( return (
<div className="px-4 py-4 pb-24 overflow-auto md:px-6 md:py-6 md:pb-8"> <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"> <div className="mx-auto max-w-7xl space-y-4">
@ -212,15 +208,7 @@ function BrandLookupPageInner({
</p> </p>
</div> </div>
{access.isLoading ? ( {planGate.isFreePlan ? (
<AccessGateLoadingState />
) : !access.enabled ? (
<AiSearchSetupGate
errorMessage={access.errorMessage ?? access.statusErrorMessage}
isRefetching={access.isRefetching}
onRetry={access.onRetry}
/>
) : planGate.isFreePlan ? (
<AiSearchPaidPlanGate <AiSearchPaidPlanGate
feature="Brand Lookup" 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." 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 { PromptExplorerLoadingState } from "@/client/features/ai-search/components/PromptExplorerLoadingState";
import { PromptExplorerHistorySection } from "@/client/features/ai-search/components/PromptExplorerHistorySection"; import { PromptExplorerHistorySection } from "@/client/features/ai-search/components/PromptExplorerHistorySection";
import { AiSearchPaidPlanGate } from "@/client/features/ai-search/components/AiSearchPaidPlanGate"; 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 { usePromptExplorerSearchHistory } from "@/client/hooks/usePromptExplorerSearchHistory";
import { import {
PROMPT_EXPLORER_MAX_PROMPT_LENGTH, PROMPT_EXPLORER_MAX_PROMPT_LENGTH,
@ -77,7 +74,6 @@ function PromptExplorerPageInner({
}: Props & { planGate: HostedPlanGateState }) { }: Props & { planGate: HostedPlanGateState }) {
const [form, setForm] = useState<PromptExplorerFormValues>(urlState); const [form, setForm] = useState<PromptExplorerFormValues>(urlState);
const [validationError, setValidationError] = useState<string | null>(null); const [validationError, setValidationError] = useState<string | null>(null);
const access = useAiSearchAccess(projectId);
const { const {
history, history,
@ -110,11 +106,11 @@ function PromptExplorerPageInner({
webSearchCountryCode: urlState.webSearchCountryCode, 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: enabled:
hasActivePrompt && hasActivePrompt && urlState.models.length > 0 && !planGate.isFreePlan,
urlState.models.length > 0 &&
!planGate.isFreePlan &&
access.enabled,
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
retry: false, retry: false,
}); });
@ -199,8 +195,6 @@ function PromptExplorerPageInner({
if (validationError) setValidationError(null); if (validationError) setValidationError(null);
}; };
if (planGate.isLoading) return null;
return ( return (
<div className="px-4 py-4 pb-24 overflow-auto md:px-6 md:py-6 md:pb-8"> <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"> <div className="mx-auto max-w-7xl space-y-4">
@ -212,15 +206,7 @@ function PromptExplorerPageInner({
</p> </p>
</div> </div>
{access.isLoading ? ( {planGate.isFreePlan ? (
<AccessGateLoadingState />
) : !access.enabled ? (
<AiSearchSetupGate
errorMessage={access.errorMessage ?? access.statusErrorMessage}
isRefetching={access.isRefetching}
onRetry={access.onRetry}
/>
) : planGate.isFreePlan ? (
<AiSearchPaidPlanGate <AiSearchPaidPlanGate
feature="Prompt Explorer" 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." 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 { const {
accessGate,
activeTabErrorMessage, activeTabErrorMessage,
activeTabQuery, activeTabQuery,
backlinksDisabledByError,
overviewErrorMessage, overviewErrorMessage,
overviewQuery, overviewQuery,
referringDomainsQuery, referringDomainsQuery,
@ -192,9 +190,6 @@ export function BacklinksPage({
</p> </p>
</div> </div>
{!accessGate.isLoading &&
accessGate.enabled &&
!backlinksDisabledByError ? (
<BacklinksSearchCard <BacklinksSearchCard
errorMessage={overviewErrorMessage} errorMessage={overviewErrorMessage}
initialValues={searchCardInitialValues} initialValues={searchCardInitialValues}
@ -208,12 +203,9 @@ export function BacklinksPage({
addSearch({ target: values.target, scope: values.scope }); addSearch({ target: values.target, scope: values.scope });
}} }}
/> />
) : null}
<BacklinksBody <BacklinksBody
projectId={projectId} projectId={projectId}
accessGate={accessGate}
backlinksDisabledByError={backlinksDisabledByError}
history={history} history={history}
historyLoaded={historyLoaded} historyLoaded={historyLoaded}
overviewData={overviewQuery.data} overviewData={overviewQuery.data}

View File

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

View File

@ -1,35 +1,4 @@
import { ShieldAlert } from "lucide-react"; 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() { export function BacklinksLoadingState() {
return ( return (
@ -90,16 +59,3 @@ export function BacklinksErrorState({
</section> </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 { useQuery } from "@tanstack/react-query";
import type { import type {
BacklinksPageProps, BacklinksPageProps,
BacklinksSearchState, BacklinksSearchState,
} from "./backlinksPageTypes"; } from "./backlinksPageTypes";
import { useAccessGate } from "@/client/features/access-gate/useAccessGate";
import { import {
getErrorCode, getErrorCode,
getStandardErrorMessage, getStandardErrorMessage,
@ -15,7 +14,6 @@ import {
getBacklinksRows, getBacklinksRows,
getBacklinksTopPages, getBacklinksTopPages,
} from "@/serverFunctions/backlinks"; } from "@/serverFunctions/backlinks";
import { getBacklinksAccessSetupStatus } from "@/serverFunctions/backlinksAccess";
import { import {
BACKLINKS_DEFAULT_SORT, BACKLINKS_DEFAULT_SORT,
backlinksRowsSortFieldSchema, backlinksRowsSortFieldSchema,
@ -76,13 +74,6 @@ export function useBacklinksPageData({
searchState, searchState,
filters, filters,
}: UseBacklinksPageDataArgs) { }: 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( const searchCardInitialValues = useMemo(
() => ({ () => ({
target: searchState.target, target: searchState.target,
@ -93,7 +84,7 @@ export function useBacklinksPageData({
const { target, scope, tab, page, pageSize, sort, order, view } = searchState; const { target, scope, tab, page, pageSize, sort, order, view } = searchState;
const rowsMode = view === "all" ? "as_is" : "one_per_domain"; 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 baseQueryKeyParts = [projectId, scope, target] as const;
const pageInputBase = { projectId, target, scope, page, pageSize }; const pageInputBase = { projectId, target, scope, page, pageSize };
@ -209,8 +200,6 @@ export function useBacklinksPageData({
overviewQuery.error, overviewQuery.error,
"Could not load backlinks data.", "Could not load backlinks data.",
); );
const backlinksDisabledByError =
getErrorCode(overviewQuery.error) === "BACKLINKS_NOT_ENABLED";
const activeTabQuery = const activeTabQuery =
tab === "backlinks" tab === "backlinks"
? rowsQuery ? rowsQuery
@ -221,28 +210,10 @@ export function useBacklinksPageData({
activeTabQuery.error, activeTabQuery.error,
"Could not load this tab.", "Could not load this tab.",
); );
const backlinksDisabledByTabError =
getErrorCode(activeTabQuery.error) === "BACKLINKS_NOT_ENABLED";
useEffect(() => {
if (
(backlinksDisabledByError || backlinksDisabledByTabError) &&
backlinksEnabled
) {
retryAccessGate();
}
}, [
backlinksDisabledByError,
backlinksDisabledByTabError,
backlinksEnabled,
retryAccessGate,
]);
return { return {
accessGate,
activeTabErrorMessage, activeTabErrorMessage,
activeTabQuery, activeTabQuery,
backlinksDisabledByError,
overviewErrorMessage, overviewErrorMessage,
overviewQuery, overviewQuery,
referringDomainsQuery, referringDomainsQuery,

View File

@ -7,7 +7,6 @@ import {
invalidateSamSessions, invalidateSamSessions,
samSessionsQueryOptions, samSessionsQueryOptions,
} from "@/client/features/sam/samQueries"; } from "@/client/features/sam/samQueries";
import { AccessGateLoadingState } from "@/client/features/access-gate/AccessGate";
import { useSamAccess } from "./useSamAccess"; import { useSamAccess } from "./useSamAccess";
import { SamSetupGate } from "./SamSetupGate"; import { SamSetupGate } from "./SamSetupGate";
import { SamConversation } from "./SamConversation"; import { SamConversation } from "./SamConversation";
@ -57,22 +56,19 @@ export function SamChat({
goToSession(firstSessionId); goToSession(firstSessionId);
}, [activeSessionId, firstSessionId, goToSession]); }, [activeSessionId, firstSessionId, goToSession]);
// Gate the whole page until OPENROUTER_API_KEY is configured — SAM cannot // SAM cannot answer a turn without OPENROUTER_API_KEY, so surface setup
// answer a single turn without it, so surface setup instructions instead of // instructions instead of letting a chat fail mid-stream. Only shown once the
// letting a chat fail mid-stream. // check confirms the key is missing (self-hosted) — never as a blocking
if (access.isLoading || !access.enabled) { // skeleton while the check is in flight.
if (access.showSetupGate) {
return ( return (
<div className="overflow-auto px-4 py-4 md:px-6 md:py-6"> <div className="overflow-auto px-4 py-4 md:px-6 md:py-6">
<div className="mx-auto max-w-3xl"> <div className="mx-auto max-w-3xl">
{access.isLoading ? (
<AccessGateLoadingState />
) : (
<SamSetupGate <SamSetupGate
errorMessage={access.errorMessage ?? access.statusErrorMessage} errorMessage={access.errorMessage}
isRefetching={access.isRefetching} isRefetching={access.isRefetching}
onRetry={access.onRetry} onRetry={access.onRetry}
/> />
)}
</div> </div>
</div> </div>
); );

View File

@ -1,5 +1,5 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { AccessGate } from "@/client/features/access-gate/AccessGate"; import { ShieldAlert, Wrench } from "lucide-react";
export function SamSetupGate({ export function SamSetupGate({
errorMessage, errorMessage,
@ -11,17 +11,21 @@ export function SamSetupGate({
onRetry: () => void; onRetry: () => void;
}) { }) {
return ( return (
<AccessGate <section>
title="Enable AI Features" <div className="rounded-2xl border border-base-300 bg-base-100 p-6 md:p-7 space-y-5">
bodyText={ <div className="flex items-start gap-3">
<> <div className="rounded-xl bg-warning/15 p-2.5 text-warning shrink-0">
SAM, OpenSEO's in-app AI agent, needs an OpenRouter API key. Create a <Wrench className="size-5" />
key on OpenRouter, set it as the <code>OPENROUTER_API_KEY</code>{" "} </div>
environment variable, restart OpenSEO, then confirm here. <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">
helperText={ 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{" "} Step-by-step instructions for every deployment are in the{" "}
<Link <Link
className="underline underline-offset-2 hover:text-base-content/70" className="underline underline-offset-2 hover:text-base-content/70"
@ -30,14 +34,35 @@ export function SamSetupGate({
OpenRouter API key setup guide OpenRouter API key setup guide
</Link> </Link>
. .
</> </div>
} </div>
buttonLabel="Confirm API Key" </div>
externalUrl="https://openrouter.ai/settings/keys"
externalLabel="Open OpenRouter Keys" <div className="flex flex-wrap items-center gap-3">
errorMessage={errorMessage} <button
isRefetching={isRefetching} className="btn btn-primary"
onRetry={onRetry} 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"; import { getSamAccessSetupStatus } from "@/serverFunctions/samAccess";
export function useSamAccess(projectId: string) { type SamAccess = {
return useAccessGate({ // 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], queryKey: ["samAccessStatus", projectId],
queryFn: () => getSamAccessSetupStatus({ data: { 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.", "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.", VALIDATION_ERROR: "Please check your input and try again.",
CRAWL_TARGET_BLOCKED: "This crawl target is blocked by security policy.", CRAWL_TARGET_BLOCKED: "This crawl target is blocked by security policy.",
BACKLINKS_NOT_ENABLED:
"Backlinks is not enabled for the connected DataForSEO account yet.",
BACKLINKS_BILLING_ISSUE: BACKLINKS_BILLING_ISSUE:
"The connected DataForSEO account has a billing or balance 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: AI_SEARCH_BILLING_ISSUE:
"The connected DataForSEO account has a billing or balance issue.", "The connected DataForSEO account has a billing or balance issue.",
DATAFORSEO_AUTH_FAILED: DATAFORSEO_AUTH_FAILED:

View File

@ -305,7 +305,6 @@ function rethrowIfBlockingAiSearchError(
result.status === "rejected" && result.status === "rejected" &&
result.reason instanceof AppError && result.reason instanceof AppError &&
(result.reason.code === "INSUFFICIENT_CREDITS" || (result.reason.code === "INSUFFICIENT_CREDITS" ||
result.reason.code === "AI_SEARCH_NOT_ENABLED" ||
result.reason.code === "AI_SEARCH_BILLING_ISSUE") result.reason.code === "AI_SEARCH_BILLING_ISSUE")
) { ) {
throw result.reason; throw result.reason;

View File

@ -286,7 +286,6 @@ function mapErrorToResult(
if ( if (
reason instanceof AppError && reason instanceof AppError &&
(reason.code === "INSUFFICIENT_CREDITS" || (reason.code === "INSUFFICIENT_CREDITS" ||
reason.code === "AI_SEARCH_NOT_ENABLED" ||
reason.code === "AI_SEARCH_BILLING_ISSUE") reason.code === "AI_SEARCH_BILLING_ISSUE")
) { ) {
// These account-level failures apply to every model, so surface one clear // These account-level failures apply to every model, so surface one clear

View File

@ -25,7 +25,7 @@ import {
type LlmResponseResult, type LlmResponseResult,
type LlmTopPagesItem, type LlmTopPagesItem,
} from "@/server/lib/dataforseoLlmSchemas"; } from "@/server/lib/dataforseoLlmSchemas";
import { createDataforseoAccessClassifier } from "@/server/lib/dataforseoAccessClassification"; import { createDataforseoBillingClassifier } from "@/server/lib/dataforseoBillingClassification";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import { aiOptimizationApi } from "@/server/lib/dataforseo/core"; import { aiOptimizationApi } from "@/server/lib/dataforseo/core";
import { import {
@ -42,11 +42,8 @@ export const CHATGPT_LANGUAGE_CODE = "en";
export type LlmPlatform = "chat_gpt" | "google"; export type LlmPlatform = "chat_gpt" | "google";
const classifyAiSearchError = createDataforseoAccessClassifier({ const classifyAiSearchError = createDataforseoBillingClassifier({
pathPrefix: "/ai_optimization/", pathPrefix: "/ai_optimization/",
notEnabledCode: "AI_SEARCH_NOT_ENABLED",
notEnabledMessage:
"AI Optimization is not enabled for the connected DataForSEO account",
billingIssueCode: "AI_SEARCH_BILLING_ISSUE", billingIssueCode: "AI_SEARCH_BILLING_ISSUE",
billingIssueMessage: billingIssueMessage:
"The connected DataForSEO account has a billing or balance issue", "The connected DataForSEO account has a billing or balance issue",

View File

@ -9,10 +9,10 @@ const { classifyBacklinksError } = vi.hoisted(() => ({
classifyBacklinksError: vi.fn(), 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. // returning our hoisted mock lets the test drive classification.
vi.mock("@/server/lib/dataforseoAccessClassification", () => ({ vi.mock("@/server/lib/dataforseoBillingClassification", () => ({
createDataforseoAccessClassifier: () => classifyBacklinksError, createDataforseoBillingClassifier: () => classifyBacklinksError,
})); }));
import { import {
@ -107,18 +107,18 @@ describe("fetchBacklinksSummary", () => {
vi.mocked(fetch).mockResolvedValue( vi.mocked(fetch).mockResolvedValue(
new Response( new Response(
JSON.stringify({ JSON.stringify({
status_code: 40204, status_code: 40200,
status_message: "Backlinks subscription required", status_message: "Account balance is too low",
tasks: [], tasks: [],
}), }),
{ status: 200, headers: { "Content-Type": "application/json" } }, { status: 200, headers: { "Content-Type": "application/json" } },
), ),
); );
classifyBacklinksError.mockImplementation((status: number | undefined) => { classifyBacklinksError.mockImplementation((status: number | undefined) => {
if (status === 40204) { if (status === 40200) {
return new AppError( return new AppError(
"BACKLINKS_NOT_ENABLED", "BACKLINKS_BILLING_ISSUE",
"Backlinks is not enabled", "The connected DataForSEO account has a billing or balance issue",
); );
} }
return null; return null;
@ -126,11 +126,11 @@ describe("fetchBacklinksSummary", () => {
await expect( await expect(
fetchBacklinksSummary({ target: "example.com" }), fetchBacklinksSummary({ target: "example.com" }),
).rejects.toMatchObject({ code: "BACKLINKS_NOT_ENABLED" }); ).rejects.toMatchObject({ code: "BACKLINKS_BILLING_ISSUE" });
expect(classifyBacklinksError).toHaveBeenCalledWith( expect(classifyBacklinksError).toHaveBeenCalledWith(
40204, 40200,
expect.stringContaining("Backlinks subscription required"), expect.stringContaining("Account balance is too low"),
"/v3/backlinks/summary/live", "/v3/backlinks/summary/live",
); );
}); });

View File

@ -10,7 +10,7 @@ import {
normalizeBacklinksSpamFilterOptions, normalizeBacklinksSpamFilterOptions,
type BacklinksSpamFilterOptions, type BacklinksSpamFilterOptions,
} from "@/types/schemas/backlinks"; } from "@/types/schemas/backlinks";
import { createDataforseoAccessClassifier } from "@/server/lib/dataforseoAccessClassification"; import { createDataforseoBillingClassifier } from "@/server/lib/dataforseoBillingClassification";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import { backlinksApi } from "@/server/lib/dataforseo/core"; import { backlinksApi } from "@/server/lib/dataforseo/core";
import { import {
@ -41,11 +41,8 @@ type BacklinksTimeseriesRequest = {
dateTo: string; dateTo: string;
}; };
const classifyBacklinksError = createDataforseoAccessClassifier({ const classifyBacklinksError = createDataforseoBillingClassifier({
pathPrefix: "/backlinks/", pathPrefix: "/backlinks/",
notEnabledCode: "BACKLINKS_NOT_ENABLED",
notEnabledMessage:
"Backlinks is not enabled for the connected DataForSEO account",
billingIssueCode: "BACKLINKS_BILLING_ISSUE", billingIssueCode: "BACKLINKS_BILLING_ISSUE",
billingIssueMessage: billingIssueMessage:
"The connected DataForSEO account has a billing or balance issue", "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 * Translates a DataForSEO HTTP/task failure into a product-specific AppError
* (e.g. "backlinks not enabled", "billing issue"). Returns null when the * (e.g. "billing issue"). Returns null when the failure isn't one this
* failure isn't one this classifier recognises, so the caller can fall back to * classifier recognises, so the caller can fall back to a generic error. See
* a generic error. See {@link createDataforseoAccessClassifier}. * {@link createDataforseoBillingClassifier}.
*/ */
export type DataforseoErrorClassifier = ( export type DataforseoErrorClassifier = (
status: number | undefined, status: number | undefined,

View File

@ -83,10 +83,12 @@ describe("assertOk", () => {
}); });
it("uses the classifier for non-charged (no-cost) failures", () => { 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 = { const task = {
status_code: 40204, status_code: 40200,
status_message: "subscription required", status_message: "balance is too low",
}; };
expect(() => expect(() =>
assertOk( assertOk(
@ -95,16 +97,16 @@ describe("assertOk", () => {
), ),
).toThrow("nope"); ).toThrow("nope");
expect(classify).toHaveBeenCalledWith( expect(classify).toHaveBeenCalledWith(
40204, 40200,
"subscription required", "balance is too low",
"/v3/backlinks/summary/live", "/v3/backlinks/summary/live",
); );
}); });
it.each([ it.each([
[40204, "BACKLINKS_NOT_ENABLED"],
[403, "BACKLINKS_NOT_ENABLED"],
[40200, "BACKLINKS_BILLING_ISSUE"], [40200, "BACKLINKS_BILLING_ISSUE"],
[40210, "BACKLINKS_BILLING_ISSUE"],
[402, "BACKLINKS_BILLING_ISSUE"],
] as const)( ] as const)(
"uses the classifier for account failure %s before charging billed task metadata", "uses the classifier for account failure %s before charging billed task metadata",
(status, code) => { (status, code) => {
@ -113,7 +115,7 @@ describe("assertOk", () => {
); );
const task = { const task = {
status_code: status, status_code: status,
status_message: "Backlinks subscription required", status_message: "Account balance is too low",
path: ["v3", "backlinks", "summary", "live"], path: ["v3", "backlinks", "summary", "live"],
cost: 0.05, cost: 0.05,
result_count: 0, result_count: 0,
@ -128,7 +130,7 @@ describe("assertOk", () => {
} }
expect(classify).toHaveBeenCalledWith( expect(classify).toHaveBeenCalledWith(
status, status,
"Backlinks subscription required", "Account balance is too low",
"/v3/backlinks/summary/live", "/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 { 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/", pathPrefix: "/backlinks/",
notEnabledCode: "BACKLINKS_NOT_ENABLED",
notEnabledMessage: "not enabled",
billingIssueCode: "BACKLINKS_BILLING_ISSUE", billingIssueCode: "BACKLINKS_BILLING_ISSUE",
billingIssueMessage: "billing issue", billingIssueMessage: "billing issue",
}); });
describe("createDataforseoAccessClassifier", () => { describe("createDataforseoBillingClassifier", () => {
it("returns null when the path is outside the configured prefix", () => { it("returns null when the path is outside the configured prefix", () => {
expect(classify(402, "payment required", "/v3/serp/google/live")).toBe( expect(classify(402, "payment required", "/v3/serp/google/live")).toBe(
null, 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])( it.each([40200, 40210, 402])(
"translates billing status %s into the configured billing error code", "translates billing status %s into the configured billing error code",
(status) => { (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([ it.each([
"insufficient funds", "insufficient funds",
"payment required", "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", () => { it("returns null when neither status nor text matches", () => {
expect(classify(500, "boom", "/v3/backlinks/summary/live")).toBe(null); expect(classify(500, "boom", "/v3/backlinks/summary/live")).toBe(null);
}); });
it("matches signals case-insensitively", () => { it("matches billing signals case-insensitively", () => {
const err = classify( const err = classify(
undefined, undefined,
"SUBSCRIPTION required", "INSUFFICIENT funds",
"/v3/backlinks/summary/live", "/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 { AppError } from "@/server/lib/errors";
import type { ErrorCode } from "@/shared/error-codes"; 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 = [ const BILLING_SIGNALS = [
"insufficient funds", "insufficient funds",
"balance is too low", "balance is too low",
@ -25,22 +11,25 @@ const BILLING_SIGNALS = [
"recharged", "recharged",
]; ];
const ACCESS_STATUS_CODES = new Set([40204, 403]);
const BILLING_STATUS_CODES = new Set([40200, 40210, 402]); const BILLING_STATUS_CODES = new Set([40200, 40210, 402]);
type DataforseoAccessClassifier = ( type DataforseoBillingClassifier = (
status: number | undefined, status: number | undefined,
details: string, details: string,
path: string, path: string,
) => AppError | null; ) => 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; pathPrefix: string;
notEnabledCode: ErrorCode;
notEnabledMessage: string;
billingIssueCode: ErrorCode; billingIssueCode: ErrorCode;
billingIssueMessage: string; billingIssueMessage: string;
}): DataforseoAccessClassifier { }): DataforseoBillingClassifier {
return (status, details, path) => { return (status, details, path) => {
if (!path.includes(config.pathPrefix)) return null; if (!path.includes(config.pathPrefix)) return null;
@ -54,13 +43,6 @@ export function createDataforseoAccessClassifier(config: {
return new AppError(config.billingIssueCode, config.billingIssueMessage); return new AppError(config.billingIssueCode, config.billingIssueMessage);
} }
const matchesAccessStatus = return null;
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);
}; };
} }

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", () => { it("captures unexpected errors and unknown failures", () => {
expect(shouldCaptureAppErrorCode("INTERNAL_ERROR")).toBe(true); expect(shouldCaptureAppErrorCode("INTERNAL_ERROR")).toBe(true);
expect(shouldCaptureAppErrorCode(undefined)).toBe(true); expect(shouldCaptureAppErrorCode(undefined)).toBe(true);
// On cloud the shared DataForSEO account has these add-ons, so these firing // A depleted DataForSEO balance is a real platform problem on cloud — keep
// signals a real platform problem — keep them reportable, don't suppress. // the billing codes reportable, don't suppress them.
expect(shouldCaptureAppErrorCode("BACKLINKS_NOT_ENABLED")).toBe(true); expect(shouldCaptureAppErrorCode("BACKLINKS_BILLING_ISSUE")).toBe(true);
expect(shouldCaptureAppErrorCode("AI_SEARCH_NOT_ENABLED")).toBe(true); expect(shouldCaptureAppErrorCode("AI_SEARCH_BILLING_ISSUE")).toBe(true);
}); });
}); });

View File

@ -12,9 +12,7 @@ const ERROR_CODES = [
"AUDIT_ALREADY_RUNNING", "AUDIT_ALREADY_RUNNING",
"VALIDATION_ERROR", "VALIDATION_ERROR",
"CRAWL_TARGET_BLOCKED", "CRAWL_TARGET_BLOCKED",
"BACKLINKS_NOT_ENABLED",
"BACKLINKS_BILLING_ISSUE", "BACKLINKS_BILLING_ISSUE",
"AI_SEARCH_NOT_ENABLED",
"AI_SEARCH_BILLING_ISSUE", "AI_SEARCH_BILLING_ISSUE",
"DATAFORSEO_AUTH_FAILED", "DATAFORSEO_AUTH_FAILED",
"RATE_LIMITED", "RATE_LIMITED",

View File

@ -7,14 +7,6 @@ import { z } from "zod";
* by server functions, services, R2 cache validation, and the client UI. * 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 // Brand Lookup
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -36,10 +36,6 @@ export const backlinksLookupSchema = z.object({
scope: backlinksTargetScopeSchema.optional(), scope: backlinksTargetScopeSchema.optional(),
}); });
export const backlinksProjectSchema = z.object({
projectId: z.string().min(1),
});
export const backlinksOverviewInputSchema = backlinksLookupSchema.extend({ export const backlinksOverviewInputSchema = backlinksLookupSchema.extend({
projectId: z.string().min(1), projectId: z.string().min(1),
}); });