feat: gate llm mentions in self hosted product (#134)

This commit is contained in:
Ben Senescu 2026-04-23 18:02:24 -04:00 committed by GitHub
parent 65d62f0eed
commit 1108cdd9a7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 748 additions and 811 deletions

View File

@ -0,0 +1,83 @@
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 btn-outline"
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

@ -0,0 +1,46 @@
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

@ -1,6 +1,5 @@
import { useEffect, useState, type FormEvent } from "react"; import { useEffect, useState, type FormEvent } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { AutumnProvider, useCustomer } from "autumn-js/react";
import { import {
AlertCircle, AlertCircle,
ArrowLeft, ArrowLeft,
@ -9,14 +8,21 @@ import {
TrendingUp, TrendingUp,
} from "lucide-react"; } from "lucide-react";
import { lookupBrand } from "@/serverFunctions/ai-search"; import { lookupBrand } from "@/serverFunctions/ai-search";
import { useSession } from "@/lib/auth-client"; import {
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection"; HostedPlanGate,
type HostedPlanGateState,
} from "@/client/features/billing/HostedPlanGate";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { BrandLookupResults } from "@/client/features/ai-search/components/BrandLookupResults"; import { BrandLookupResults } from "@/client/features/ai-search/components/BrandLookupResults";
import { BrandLookupSearchCard } from "@/client/features/ai-search/components/BrandLookupSearchCard"; import { BrandLookupSearchCard } from "@/client/features/ai-search/components/BrandLookupSearchCard";
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 {
AiSearchAccessLoadingState,
AiSearchSetupGate,
} from "@/client/features/ai-search/components/AiSearchSetupGate";
import { useAiSearchAccess } from "@/client/features/ai-search/useAiSearchAccess";
import { useBrandLookupSearchHistory } from "@/client/hooks/useBrandLookupSearchHistory"; import { useBrandLookupSearchHistory } from "@/client/hooks/useBrandLookupSearchHistory";
import { BRAND_LOOKUP_MAX_INPUT_LENGTH } from "@/types/schemas/ai-search"; import { BRAND_LOOKUP_MAX_INPUT_LENGTH } from "@/types/schemas/ai-search";
@ -46,9 +52,9 @@ const BRAND_LOOKUP_BULLETS = [
export function BrandLookupPage(props: Props) { export function BrandLookupPage(props: Props) {
return ( return (
<AutumnProvider> <HostedPlanGate>
<BrandLookupPageInner {...props} /> {(planGate) => <BrandLookupPageInner {...props} planGate={planGate} />}
</AutumnProvider> </HostedPlanGate>
); );
} }
@ -56,18 +62,12 @@ function BrandLookupPageInner({
projectId, projectId,
initialQuery, initialQuery,
onQueryChange, onQueryChange,
}: Props) { planGate,
}: Props & { planGate: HostedPlanGateState }) {
const [query, setQuery] = useState(initialQuery); const [query, setQuery] = useState(initialQuery);
const [validationError, setValidationError] = useState<string | null>(null); const [validationError, setValidationError] = useState<string | null>(null);
const { data: session } = useSession(); const access = useAiSearchAccess(projectId);
const customerQuery = useCustomer({
queryOptions: { enabled: Boolean(session?.user?.id) },
});
const planKnown = customerQuery.isSuccess || customerQuery.isError;
const isFreePlan =
!!customerQuery.data &&
getCustomerPlanStatus(customerQuery.data) === "free";
const trimmedInitialQuery = initialQuery.trim(); const trimmedInitialQuery = initialQuery.trim();
const hasActiveQuery = trimmedInitialQuery.length > 0; const hasActiveQuery = trimmedInitialQuery.length > 0;
@ -83,7 +83,7 @@ function BrandLookupPageInner({
languageCode: "en", languageCode: "en",
}, },
}), }),
enabled: hasActiveQuery && !isFreePlan, enabled: hasActiveQuery && !planGate.isFreePlan && access.enabled,
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
retry: false, retry: false,
}); });
@ -137,7 +137,7 @@ function BrandLookupPageInner({
: null; : null;
const resultData = hasActiveQuery ? lookupQuery.data : undefined; const resultData = hasActiveQuery ? lookupQuery.data : undefined;
if (!planKnown) return 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">
@ -149,7 +149,15 @@ function BrandLookupPageInner({
</p> </p>
</div> </div>
{isFreePlan ? ( {access.isLoading ? (
<AiSearchAccessLoadingState />
) : !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, the prompts driving them, and the pages cited alongside yours." description="See how ChatGPT and Google AI Overview cite any brand or domain — total mentions, the prompts driving them, and the pages cited alongside yours."

View File

@ -1,6 +1,5 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { AutumnProvider, useCustomer } from "autumn-js/react";
import { import {
AlertCircle, AlertCircle,
ArrowLeft, ArrowLeft,
@ -9,14 +8,21 @@ import {
Sparkles, Sparkles,
} from "lucide-react"; } from "lucide-react";
import { explorePrompt } from "@/serverFunctions/ai-search"; import { explorePrompt } from "@/serverFunctions/ai-search";
import { useSession } from "@/lib/auth-client"; import {
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection"; HostedPlanGate,
type HostedPlanGateState,
} from "@/client/features/billing/HostedPlanGate";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { PromptExplorerForm } from "@/client/features/ai-search/components/PromptExplorerForm"; import { PromptExplorerForm } from "@/client/features/ai-search/components/PromptExplorerForm";
import { PromptExplorerResults } from "@/client/features/ai-search/components/PromptExplorerResults"; import { PromptExplorerResults } from "@/client/features/ai-search/components/PromptExplorerResults";
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 {
AiSearchAccessLoadingState,
AiSearchSetupGate,
} from "@/client/features/ai-search/components/AiSearchSetupGate";
import { useAiSearchAccess } from "@/client/features/ai-search/useAiSearchAccess";
import { import {
usePromptExplorerSearchHistory, usePromptExplorerSearchHistory,
type PromptExplorerSearchHistoryItem, type PromptExplorerSearchHistoryItem,
@ -68,24 +74,19 @@ const INITIAL_FORM_STATE: FormState = {
export function PromptExplorerPage(props: Props) { export function PromptExplorerPage(props: Props) {
return ( return (
<AutumnProvider> <HostedPlanGate>
<PromptExplorerPageInner {...props} /> {(planGate) => <PromptExplorerPageInner {...props} planGate={planGate} />}
</AutumnProvider> </HostedPlanGate>
); );
} }
function PromptExplorerPageInner({ projectId }: Props) { function PromptExplorerPageInner({
projectId,
planGate,
}: Props & { planGate: HostedPlanGateState }) {
const [form, setForm] = useState<FormState>(INITIAL_FORM_STATE); const [form, setForm] = useState<FormState>(INITIAL_FORM_STATE);
const [validationError, setValidationError] = useState<string | null>(null); const [validationError, setValidationError] = useState<string | null>(null);
const access = useAiSearchAccess(projectId);
const { data: session } = useSession();
const customerQuery = useCustomer({
queryOptions: { enabled: Boolean(session?.user?.id) },
});
const planKnown = customerQuery.isSuccess || customerQuery.isError;
const isFreePlan =
!!customerQuery.data &&
getCustomerPlanStatus(customerQuery.data) === "free";
const { const {
history, history,
@ -177,7 +178,7 @@ function PromptExplorerPageInner({ projectId }: Props) {
if (validationError) setValidationError(null); if (validationError) setValidationError(null);
}; };
if (!planKnown) return 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">
@ -190,7 +191,15 @@ function PromptExplorerPageInner({ projectId }: Props) {
</p> </p>
</div> </div>
{isFreePlan ? ( {access.isLoading ? (
<AiSearchAccessLoadingState />
) : !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

@ -0,0 +1,50 @@
import {
AccessGate,
AccessGateLoadingState,
} from "@/client/features/access-gate/AccessGate";
export function AiSearchAccessLoadingState() {
return <AccessGateLoadingState />;
}
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

@ -0,0 +1,10 @@
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

@ -9,7 +9,6 @@ import {
} from "./useBacklinksPageData"; } from "./useBacklinksPageData";
import { useBacklinksFilters } from "./useBacklinksFilters"; import { useBacklinksFilters } from "./useBacklinksFilters";
import { useBacklinksSearchHistory } from "@/client/hooks/useBacklinksSearchHistory"; import { useBacklinksSearchHistory } from "@/client/hooks/useBacklinksSearchHistory";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
export function BacklinksPage({ export function BacklinksPage({
projectId, projectId,
@ -18,17 +17,13 @@ export function BacklinksPage({
}: BacklinksPageProps) { }: BacklinksPageProps) {
const filters = useBacklinksFilters(); const filters = useBacklinksFilters();
const { const {
accessStatus, accessGate,
accessStatusErrorMessage,
accessStatusQuery,
activeTabErrorMessage, activeTabErrorMessage,
backlinksDisabledByError, backlinksDisabledByError,
backlinksEnabled,
overviewErrorMessage, overviewErrorMessage,
overviewQuery, overviewQuery,
referringDomainsQuery, referringDomainsQuery,
searchCardInitialValues, searchCardInitialValues,
testAccessMutation,
topPagesQuery, topPagesQuery,
} = useBacklinksPageData({ } = useBacklinksPageData({
projectId, projectId,
@ -63,8 +58,8 @@ export function BacklinksPage({
</p> </p>
</div> </div>
{!accessStatusQuery.isLoading && {!accessGate.isLoading &&
backlinksEnabled && accessGate.enabled &&
!backlinksDisabledByError ? ( !backlinksDisabledByError ? (
<BacklinksSearchCard <BacklinksSearchCard
errorMessage={overviewErrorMessage} errorMessage={overviewErrorMessage}
@ -82,13 +77,10 @@ export function BacklinksPage({
) : null} ) : null}
<BacklinksBody <BacklinksBody
accessStatus={accessStatus} accessGate={accessGate}
accessStatusError={accessStatusErrorMessage}
backlinksDisabledByError={backlinksDisabledByError} backlinksDisabledByError={backlinksDisabledByError}
backlinksEnabled={backlinksEnabled}
history={history} history={history}
historyLoaded={historyLoaded} historyLoaded={historyLoaded}
isAccessStatusLoading={accessStatusQuery.isLoading}
overviewData={overviewQuery.data} overviewData={overviewQuery.data}
overviewError={overviewErrorMessage} overviewError={overviewErrorMessage}
overviewLoading={overviewQuery.isLoading} overviewLoading={overviewQuery.isLoading}
@ -101,23 +93,12 @@ export function BacklinksPage({
referringDomainsQuery.isLoading) || referringDomainsQuery.isLoading) ||
(searchState.tab === "pages" && topPagesQuery.isLoading) (searchState.tab === "pages" && topPagesQuery.isLoading)
} }
testError={
testAccessMutation.error
? getStandardErrorMessage(
testAccessMutation.error,
"Could not test Backlinks access.",
)
: null
}
testIsPending={testAccessMutation.isPending}
topPages={topPagesQuery.data} topPages={topPagesQuery.data}
onRemoveHistoryItem={removeHistoryItem} onRemoveHistoryItem={removeHistoryItem}
onRetryAccess={() => void accessStatusQuery.refetch()}
onSelectHistoryItem={handleHistorySelect} onSelectHistoryItem={handleHistorySelect}
onShowHistory={() => navigateToBacklinksHistory(navigate)} onShowHistory={() => navigateToBacklinksHistory(navigate)}
onSetActiveTab={(tab) => navigateToBacklinksTab(navigate, tab)} onSetActiveTab={(tab) => navigateToBacklinksTab(navigate, tab)}
onRetryOverview={() => void overviewQuery.refetch()} onRetryOverview={() => void overviewQuery.refetch()}
onTestAccess={() => testAccessMutation.mutate()}
/> />
</div> </div>
</div> </div>

View File

@ -12,12 +12,12 @@ import {
import { BacklinksHistorySection } from "./BacklinksHistorySection"; import { BacklinksHistorySection } from "./BacklinksHistorySection";
import type { BacklinksSearchHistoryItem } from "@/client/hooks/useBacklinksSearchHistory"; import type { BacklinksSearchHistoryItem } from "@/client/hooks/useBacklinksSearchHistory";
import type { import type {
BacklinksAccessStatusData,
BacklinksOverviewData, BacklinksOverviewData,
BacklinksReferringDomainsData, BacklinksReferringDomainsData,
BacklinksSearchState, BacklinksSearchState,
BacklinksTopPagesData, BacklinksTopPagesData,
} from "./backlinksPageTypes"; } from "./backlinksPageTypes";
import type { UseAccessGateResult } from "@/client/features/access-gate/useAccessGate";
import { buildSummaryStats } from "./backlinksPageUtils"; import { buildSummaryStats } from "./backlinksPageUtils";
import { import {
filterBacklinkRows, filterBacklinkRows,
@ -27,13 +27,10 @@ import {
import type { BacklinksFiltersState } from "./useBacklinksFilters"; import type { BacklinksFiltersState } from "./useBacklinksFilters";
type BacklinksBodyProps = { type BacklinksBodyProps = {
accessStatus: BacklinksAccessStatusData | undefined; accessGate: UseAccessGateResult;
accessStatusError: string | null;
backlinksDisabledByError: boolean; backlinksDisabledByError: boolean;
backlinksEnabled: boolean;
history: BacklinksSearchHistoryItem[]; history: BacklinksSearchHistoryItem[];
historyLoaded: boolean; historyLoaded: boolean;
isAccessStatusLoading: boolean;
overviewData: BacklinksOverviewData | undefined; overviewData: BacklinksOverviewData | undefined;
overviewError: string | null; overviewError: string | null;
overviewLoading: boolean; overviewLoading: boolean;
@ -42,26 +39,19 @@ type BacklinksBodyProps = {
filters: BacklinksFiltersState; filters: BacklinksFiltersState;
tabErrorMessage: string | null; tabErrorMessage: string | null;
tabLoading: boolean; tabLoading: boolean;
testError: string | null;
testIsPending: boolean;
topPages: BacklinksTopPagesData | undefined; topPages: BacklinksTopPagesData | undefined;
onRemoveHistoryItem: (timestamp: number) => void; onRemoveHistoryItem: (timestamp: number) => void;
onRetryAccess: () => void;
onSelectHistoryItem: (item: BacklinksSearchHistoryItem) => void; onSelectHistoryItem: (item: BacklinksSearchHistoryItem) => void;
onShowHistory: () => void; onShowHistory: () => void;
onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void; onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void;
onRetryOverview: () => void; onRetryOverview: () => void;
onTestAccess: () => void;
}; };
export function BacklinksBody({ export function BacklinksBody({
accessStatus, accessGate,
accessStatusError,
backlinksDisabledByError, backlinksDisabledByError,
backlinksEnabled,
history, history,
historyLoaded, historyLoaded,
isAccessStatusLoading,
overviewData, overviewData,
overviewError, overviewError,
overviewLoading, overviewLoading,
@ -70,16 +60,12 @@ export function BacklinksBody({
filters, filters,
tabErrorMessage, tabErrorMessage,
tabLoading, tabLoading,
testError,
testIsPending,
topPages, topPages,
onRemoveHistoryItem, onRemoveHistoryItem,
onRetryAccess,
onSelectHistoryItem, onSelectHistoryItem,
onShowHistory, onShowHistory,
onSetActiveTab, onSetActiveTab,
onRetryOverview, onRetryOverview,
onTestAccess,
}: BacklinksBodyProps) { }: BacklinksBodyProps) {
const mergedData = useMemo( const mergedData = useMemo(
() => mergeTabData(overviewData, referringDomains, topPages), () => mergeTabData(overviewData, referringDomains, topPages),
@ -111,26 +97,25 @@ export function BacklinksBody({
[mergedData], [mergedData],
); );
if (isAccessStatusLoading) { if (accessGate.isLoading) {
return <BacklinksAccessLoadingState />; return <BacklinksAccessLoadingState />;
} }
if (accessStatusError) { if (accessGate.statusErrorMessage) {
return ( return (
<BacklinksErrorState <BacklinksErrorState
errorMessage={accessStatusError} errorMessage={accessGate.statusErrorMessage}
onRetry={onRetryAccess} onRetry={accessGate.onRetry}
/> />
); );
} }
if (!backlinksEnabled || backlinksDisabledByError) { if (!accessGate.enabled || backlinksDisabledByError) {
return ( return (
<BacklinksSetupGate <BacklinksSetupGate
status={accessStatus} errorMessage={accessGate.errorMessage}
isTesting={testIsPending} isRefetching={accessGate.isRefetching}
testError={testError} onRetry={accessGate.onRetry}
onTest={onTestAccess}
/> />
); );
} }

View File

@ -1,73 +1,40 @@
import { ShieldAlert, Wrench } from "lucide-react"; import { ShieldAlert } from "lucide-react";
import type { BacklinksAccessStatusData } from "./backlinksPageTypes"; import {
import { formatRelativeTimestamp } from "./backlinksPageUtils"; AccessGate,
AccessGateLoadingState,
} from "@/client/features/access-gate/AccessGate";
export function BacklinksAccessLoadingState() { export function BacklinksAccessLoadingState() {
return ( return <AccessGateLoadingState />;
<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 BacklinksSetupGate({ export function BacklinksSetupGate({
status, errorMessage,
isTesting, isRefetching,
testError, onRetry,
onTest,
}: { }: {
status: BacklinksAccessStatusData | undefined; errorMessage: string | null;
isTesting: boolean; isRefetching: boolean;
testError: string | null; onRetry: () => void;
onTest: () => void;
}) { }) {
return ( return (
<section> <AccessGate
<div className="rounded-2xl border border-base-300 bg-base-100 p-6 md:p-7 space-y-5"> title="Enable Backlinks"
<div className="flex items-start gap-3"> 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."
<div className="rounded-xl bg-warning/15 p-2.5 text-warning shrink-0"> helperText={
<Wrench className="size-5" /> <>
</div> We are also planning a Backlinks API so self-hosted apps can use
<div className="max-w-3xl space-y-1.5"> OpenSEO's backlinks data directly. Until then,{" "}
<h2 className="text-xl font-semibold">Enable Backlinks</h2> <InlineManagedOpenSeoLink />.
<p className="text-sm text-base-content/68"> </>
Backlinks is not enabled for your DataForSEO account yet. Turn it }
on in DataForSEO, then test access here. buttonLabel="Confirm DataForSEO Access"
</p> externalUrl="https://app.dataforseo.com/api-access-subscriptions"
<p className="text-xs text-base-content/50"> externalLabel="Open DataForSEO Backlinks"
DataForSEO offers a free 14-day trial for Backlinks. Then, it's errorMessage={errorMessage}
$100/month. We're gauging interest in building out a lower-cost isRefetching={isRefetching}
alternative, <InlineMailingListLink /> if you're interested. onRetry={onRetry}
</p> />
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
className="btn btn-primary"
onClick={onTest}
disabled={isTesting}
>
{isTesting ? "Confirming..." : "Confirm Backlinks Access"}
</button>
<a
className="btn btn-outline"
href="https://app.dataforseo.com/api-access-subscriptions"
target="_blank"
rel="noreferrer"
>
Open DataForSEO Backlinks
</a>
</div>
<BacklinksSetupFeedback status={status} testError={testError} />
</div>
</section>
); );
} }
@ -131,45 +98,15 @@ export function BacklinksErrorState({
); );
} }
function BacklinksSetupFeedback({ function InlineManagedOpenSeoLink() {
status,
testError,
}: {
status: BacklinksAccessStatusData | undefined;
testError: string | null;
}) {
return (
<div className="space-y-3">
{status?.lastCheckedAt ? (
<div className="text-sm text-base-content/60">
Last checked {formatRelativeTimestamp(status.lastCheckedAt)}.
</div>
) : null}
{status?.lastErrorMessage ? (
<div className="alert alert-warning">
<ShieldAlert className="size-4 shrink-0" />
<span>{status.lastErrorMessage}</span>
</div>
) : null}
{testError ? (
<div className="alert alert-error">
<ShieldAlert className="size-4 shrink-0" />
<span>{testError}</span>
</div>
) : null}
</div>
);
}
function InlineMailingListLink() {
return ( return (
<a <a
className="underline underline-offset-2 hover:text-base-content/70" className="underline underline-offset-2 hover:text-base-content/70"
href="https://openseo.so" href="https://openseo.so/?utm_source=self_hosted_app&utm_medium=access_gate&utm_campaign=backlinks"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
join the OpenSEO mailing list use managed OpenSEO
</a> </a>
); );
} }

View File

@ -7,14 +7,10 @@ import type {
getBacklinksReferringDomains, getBacklinksReferringDomains,
getBacklinksTopPages, getBacklinksTopPages,
} from "@/serverFunctions/backlinks"; } from "@/serverFunctions/backlinks";
import type { getBacklinksAccessSetupStatus } from "@/serverFunctions/backlinksAccess";
export type BacklinksOverviewData = Awaited< export type BacklinksOverviewData = Awaited<
ReturnType<typeof getBacklinksOverview> ReturnType<typeof getBacklinksOverview>
>; >;
export type BacklinksAccessStatusData = Awaited<
ReturnType<typeof getBacklinksAccessSetupStatus>
>;
export type BacklinksReferringDomainsData = Awaited< export type BacklinksReferringDomainsData = Awaited<
ReturnType<typeof getBacklinksReferringDomains> ReturnType<typeof getBacklinksReferringDomains>
>; >;

View File

@ -1,9 +1,10 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { useMutation, 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,
@ -13,10 +14,7 @@ import {
getBacklinksReferringDomains, getBacklinksReferringDomains,
getBacklinksTopPages, getBacklinksTopPages,
} from "@/serverFunctions/backlinks"; } from "@/serverFunctions/backlinks";
import { import { getBacklinksAccessSetupStatus } from "@/serverFunctions/backlinksAccess";
getBacklinksAccessSetupStatus,
testBacklinksAccess,
} from "@/serverFunctions/backlinksAccess";
import { getPersistedBacklinksSearchScope } from "./backlinksSearchScope"; import { getPersistedBacklinksSearchScope } from "./backlinksSearchScope";
type UseBacklinksPageDataArgs = { type UseBacklinksPageDataArgs = {
@ -40,18 +38,13 @@ export function useBacklinksPageData({
projectId, projectId,
searchState, searchState,
}: UseBacklinksPageDataArgs) { }: UseBacklinksPageDataArgs) {
const accessStatusQuery = useQuery({ const accessGate = useAccessGate({
queryKey: ["backlinksAccessStatus", projectId], queryKey: ["backlinksAccessStatus", projectId],
queryFn: () => getBacklinksAccessSetupStatus({ data: { projectId } }), queryFn: () => getBacklinksAccessSetupStatus({ data: { projectId } }),
statusErrorFallback: "Could not load Backlinks setup status.",
}); });
const accessStatus = accessStatusQuery.data; const backlinksEnabled = accessGate.enabled;
const accessStatusErrorMessage = accessStatusQuery.error const retryAccessGate = accessGate.onRetry;
? getStandardErrorMessage(
accessStatusQuery.error,
"Could not load Backlinks setup status.",
)
: null;
const backlinksEnabled = accessStatus?.enabled ?? false;
const requestInput = buildBacklinksRequestInput(projectId, searchState); const requestInput = buildBacklinksRequestInput(projectId, searchState);
const searchCardInitialValues = useMemo( const searchCardInitialValues = useMemo(
() => ({ () => ({
@ -61,13 +54,6 @@ export function useBacklinksPageData({
[searchState.scope, searchState.target], [searchState.scope, searchState.target],
); );
const testAccessMutation = useMutation({
mutationFn: () => testBacklinksAccess({ data: { projectId } }),
onSuccess: async () => {
await accessStatusQuery.refetch();
},
});
const baseQueryKeyParts = [ const baseQueryKeyParts = [
projectId, projectId,
searchState.scope, searchState.scope,
@ -118,29 +104,25 @@ export function useBacklinksPageData({
useEffect(() => { useEffect(() => {
if ( if (
(backlinksDisabledByError || backlinksDisabledByTabError) && (backlinksDisabledByError || backlinksDisabledByTabError) &&
accessStatus?.enabled backlinksEnabled
) { ) {
void accessStatusQuery.refetch(); retryAccessGate();
} }
}, [ }, [
accessStatus?.enabled,
accessStatusQuery,
backlinksDisabledByError, backlinksDisabledByError,
backlinksDisabledByTabError, backlinksDisabledByTabError,
backlinksEnabled,
retryAccessGate,
]); ]);
return { return {
accessStatus, accessGate,
accessStatusErrorMessage,
accessStatusQuery,
activeTabErrorMessage, activeTabErrorMessage,
backlinksDisabledByError, backlinksDisabledByError,
backlinksEnabled,
overviewErrorMessage, overviewErrorMessage,
overviewQuery, overviewQuery,
referringDomainsQuery, referringDomainsQuery,
searchCardInitialValues, searchCardInitialValues,
testAccessMutation,
topPagesQuery, topPagesQuery,
}; };
} }

View File

@ -0,0 +1,50 @@
import type { ReactNode } from "react";
import { AutumnProvider, useCustomer } from "autumn-js/react";
import { useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
export type HostedPlanGateState = {
isLoading: boolean;
isFreePlan: boolean;
};
const SELF_HOSTED_PLAN_GATE: HostedPlanGateState = {
isLoading: false,
isFreePlan: false,
};
export function HostedPlanGate({
children,
}: {
children: (state: HostedPlanGateState) => ReactNode;
}) {
if (!isHostedClientAuthMode()) {
return children(SELF_HOSTED_PLAN_GATE);
}
return (
<AutumnProvider>
<HostedPlanGateContent>{children}</HostedPlanGateContent>
</AutumnProvider>
);
}
function HostedPlanGateContent({
children,
}: {
children: (state: HostedPlanGateState) => ReactNode;
}) {
const { data: session, isPending: isSessionPending } = useSession();
const hasSession = Boolean(session?.user?.id);
const customerQuery = useCustomer({
queryOptions: { enabled: hasSession },
});
return children({
isLoading: isSessionPending || !hasSession || customerQuery.isLoading,
isFreePlan:
!!customerQuery.data &&
getCustomerPlanStatus(customerQuery.data) === "free",
});
}

View File

@ -18,6 +18,10 @@ const STANDARD_MESSAGES: Record<ErrorCode, string> = {
"Backlinks is not enabled for the connected DataForSEO account yet.", "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:
"The connected DataForSEO account has a billing or balance issue.",
RATE_LIMITED: "Too many requests. Please wait and try again.", RATE_LIMITED: "Too many requests. Please wait and try again.",
CONFLICT: "This request conflicts with existing data.", CONFLICT: "This request conflicts with existing data.",
INTERNAL_ERROR: INTERNAL_ERROR:

View File

@ -66,18 +66,7 @@ export async function getBrandLookup(
), ),
); );
// Credits exhaustion is global, not per-platform — re-throw immediately so rethrowIfBlockingAiSearchError(settled);
// the user gets a single clear "out of credits" error instead of a
// half-rendered result.
for (const settledResult of settled) {
if (
settledResult.status === "rejected" &&
settledResult.reason instanceof AppError &&
settledResult.reason.code === "INSUFFICIENT_CREDITS"
) {
throw settledResult.reason;
}
}
const platformBundles: PlatformOutcome[] = settled.map((settledResult, i) => { const platformBundles: PlatformOutcome[] = settled.map((settledResult, i) => {
const platform = PLATFORMS[i]; const platform = PLATFORMS[i];
@ -173,7 +162,7 @@ async function fetchPlatformData(
}), }),
]); ]);
rethrowIfCreditsExhausted(aggregated, topPages, mentions); rethrowIfBlockingAiSearchError([aggregated, topPages, mentions]);
// If every sub-call failed we have nothing to render for this platform — // If every sub-call failed we have nothing to render for this platform —
// reject so the outer `allSucceeded` gate refuses to cache a blank result. // reject so the outer `allSucceeded` gate refuses to cache a blank result.
@ -190,14 +179,16 @@ async function fetchPlatformData(
}; };
} }
function rethrowIfCreditsExhausted( function rethrowIfBlockingAiSearchError(
...results: Array<PromiseSettledResult<unknown>> results: Array<PromiseSettledResult<unknown>>,
): void { ): void {
for (const result of results) { for (const result of results) {
if ( if (
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")
) { ) {
throw result.reason; throw result.reason;
} }

View File

@ -276,10 +276,14 @@ function mapErrorToResult(
model: PromptExplorerModel, model: PromptExplorerModel,
reason: unknown, reason: unknown,
): PromptExplorerModelResult { ): PromptExplorerModelResult {
if (reason instanceof AppError && reason.code === "INSUFFICIENT_CREDITS") { if (
// Re-throw INSUFFICIENT_CREDITS so the whole request surfaces it instead reason instanceof AppError &&
// of silently degrading to "Claude failed" — credits exhaustion is global, (reason.code === "INSUFFICIENT_CREDITS" ||
// not per-model. reason.code === "AI_SEARCH_NOT_ENABLED" ||
reason.code === "AI_SEARCH_BILLING_ISSUE")
) {
// These account-level failures apply to every model, so surface one clear
// error instead of silently degrading to per-model failures.
throw reason; throw reason;
} }

View File

@ -1,39 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
buildVerifiedBacklinksAccessStatus,
getBacklinksAccessStatus,
setBacklinksAccessStatus,
} from "@/server/features/backlinks/backlinksAccess";
const { kvState } = vi.hoisted(() => ({
kvState: new Map<string, string>(),
}));
vi.mock("@/server/lib/runtime-env", () => ({
getEnvValue: vi.fn(async () => undefined),
isHostedServerAuthMode: vi.fn(async () => false),
getWorkersBinding: vi.fn(async () => ({
get: vi.fn(async (key: string) => kvState.get(key) ?? null),
put: vi.fn(async (key: string, value: string) => {
kvState.set(key, value);
}),
})),
}));
describe("backlinksAccess", () => {
beforeEach(() => {
kvState.clear();
});
it("stores access status globally", async () => {
const checkedAt = "2026-03-14T00:00:00.000Z";
await setBacklinksAccessStatus(
buildVerifiedBacklinksAccessStatus(checkedAt),
);
await expect(getBacklinksAccessStatus()).resolves.toMatchObject({
enabled: true,
verifiedAt: checkedAt,
});
});
});

View File

@ -1,130 +0,0 @@
import { z } from "zod";
import {
getWorkersBinding,
isHostedServerAuthMode,
} from "@/server/lib/runtime-env";
const BACKLINKS_ACCESS_STATUS_KEY = "settings:backlinks-access:v2:global";
const backlinksAccessStatusSchema = z.object({
enabled: z.boolean(),
verifiedAt: z.string().nullable(),
lastCheckedAt: z.string().nullable(),
lastErrorCode: z.string().nullable(),
lastErrorMessage: z.string().nullable(),
});
type BacklinksAccessStatus = z.infer<typeof backlinksAccessStatusSchema>;
const BACKLINKS_NOT_ENABLED_MESSAGE =
"Backlinks access check failed - it's still not enabled for your DataForSEO account. Enable it in DataForSEO, then try again.";
export async function getBacklinksAccessStatus(): Promise<BacklinksAccessStatus> {
if (await isHostedServerAuthMode()) {
// Hosted mode treats backlinks as platform-managed, so we intentionally
// skip self-service verification and surface backlinks as available.
return getHostedBacklinksAccessStatus();
}
const kv = await getKvNamespace();
const raw = await kv.get(BACKLINKS_ACCESS_STATUS_KEY, "text");
if (!raw) {
return getDefaultBacklinksAccessStatus();
}
const json = parseJsonUnknown(raw);
if (json === null) {
return getDefaultBacklinksAccessStatus();
}
const parsed = backlinksAccessStatusSchema.safeParse(json);
if (!parsed.success) {
return getDefaultBacklinksAccessStatus();
}
return parsed.data;
}
export async function setBacklinksAccessStatus(
status: BacklinksAccessStatus,
): Promise<void> {
if (await isHostedServerAuthMode()) {
return;
}
const kv = await getKvNamespace();
await kv.put(BACKLINKS_ACCESS_STATUS_KEY, JSON.stringify(status));
}
export function buildVerifiedBacklinksAccessStatus(
checkedAt: string,
): BacklinksAccessStatus {
return {
enabled: true,
verifiedAt: checkedAt,
lastCheckedAt: checkedAt,
lastErrorCode: null,
lastErrorMessage: null,
};
}
export function buildBacklinksDisabledAccessStatus(
checkedAt: string,
errorCode: string,
): BacklinksAccessStatus {
return {
enabled: false,
verifiedAt: null,
lastCheckedAt: checkedAt,
lastErrorCode: errorCode,
lastErrorMessage: BACKLINKS_NOT_ENABLED_MESSAGE,
};
}
function getDefaultBacklinksAccessStatus(): BacklinksAccessStatus {
return {
enabled: false,
verifiedAt: null,
lastCheckedAt: null,
lastErrorCode: null,
lastErrorMessage: null,
};
}
function getHostedBacklinksAccessStatus(): BacklinksAccessStatus {
return {
enabled: true,
verifiedAt: null,
lastCheckedAt: null,
lastErrorCode: null,
lastErrorMessage: null,
};
}
async function getKvNamespace(): Promise<KVNamespace> {
const binding = await getWorkersBinding("KV");
if (isKvNamespace(binding)) {
return binding;
}
throw new Error("KV binding is not configured correctly");
}
function isKvNamespace(value: unknown): value is KVNamespace {
return (
typeof value === "object" &&
value !== null &&
"get" in value &&
typeof value.get === "function" &&
"put" in value &&
typeof value.put === "function"
);
}
function parseJsonUnknown(raw: string): unknown {
try {
return JSON.parse(raw) as unknown;
} catch {
return null;
}
}

View File

@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { createDataforseoAccessClassifier } from "@/server/lib/dataforseoAccessClassification";
const classify = createDataforseoAccessClassifier({
pathPrefix: "/backlinks/",
notEnabledCode: "BACKLINKS_NOT_ENABLED",
notEnabledMessage: "not enabled",
billingIssueCode: "BACKLINKS_BILLING_ISSUE",
billingIssueMessage: "billing issue",
});
describe("createDataforseoAccessClassifier", () => {
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) => {
const err = classify(status, "", "/v3/backlinks/summary/live");
expect(err?.code).toBe("BACKLINKS_BILLING_ISSUE");
},
);
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",
"balance is too low",
"problem billing",
"account was not recharged",
])(
"translates billing signal %s into the configured billing code",
(message) => {
const err = classify(undefined, message, "/v3/backlinks/summary/live");
expect(err?.code).toBe("BACKLINKS_BILLING_ISSUE");
},
);
it("returns null when neither status nor text matches", () => {
expect(classify(500, "boom", "/v3/backlinks/summary/live")).toBe(null);
});
it("matches signals case-insensitively", () => {
const err = classify(
undefined,
"SUBSCRIPTION required",
"/v3/backlinks/summary/live",
);
expect(err?.code).toBe("BACKLINKS_NOT_ENABLED");
});
});

View File

@ -0,0 +1,66 @@
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",
"payment required",
"billing",
"balance",
"problem billing",
"recharged",
];
const ACCESS_STATUS_CODES = new Set([40204, 403]);
const BILLING_STATUS_CODES = new Set([40200, 40210, 402]);
type DataforseoAccessClassifier = (
status: number | undefined,
details: string,
path: string,
) => AppError | null;
export function createDataforseoAccessClassifier(config: {
pathPrefix: string;
notEnabledCode: ErrorCode;
notEnabledMessage: string;
billingIssueCode: ErrorCode;
billingIssueMessage: string;
}): DataforseoAccessClassifier {
return (status, details, path) => {
if (!path.includes(config.pathPrefix)) return null;
const text = details.toLowerCase();
const matchesBillingStatus =
status != null && BILLING_STATUS_CODES.has(status);
const matchesBillingText = BILLING_SIGNALS.some((signal) =>
text.includes(signal),
);
if (matchesBillingStatus || matchesBillingText) {
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);
};
}

View File

@ -0,0 +1,83 @@
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) {
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,21 +1,28 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import type * as DataforseoBacklinksSupport from "@/server/lib/dataforseoBacklinksSupport";
vi.mock("@/server/lib/runtime-env", () => ({ vi.mock("@/server/lib/runtime-env", () => ({
getRequiredEnvValue: vi.fn(async () => "test-api-key"), getRequiredEnvValue: vi.fn(async () => "test-api-key"),
})); }));
vi.mock("@/server/lib/dataforseoBacklinksAccount", () => ({ const { classifyBacklinksError } = vi.hoisted(() => ({
classifyBacklinksErrorWithAccountState: vi.fn(), classifyBacklinksError: vi.fn(),
})); }));
vi.mock("@/server/lib/dataforseoBacklinksSupport", async () => {
const actual = await vi.importActual<typeof DataforseoBacklinksSupport>(
"@/server/lib/dataforseoBacklinksSupport",
);
return { ...actual, classifyBacklinksError };
});
import { import {
fetchBacklinksHistoryRaw, fetchBacklinksHistoryRaw,
fetchBacklinksRowsRaw, fetchBacklinksRowsRaw,
fetchBacklinksSummaryRaw, fetchBacklinksSummaryRaw,
normalizeBacklinksTarget, normalizeBacklinksTarget,
} from "@/server/lib/dataforseoBacklinks"; } from "@/server/lib/dataforseoBacklinks";
import { classifyBacklinksErrorWithAccountState } from "@/server/lib/dataforseoBacklinksAccount";
describe("normalizeBacklinksTarget", () => { describe("normalizeBacklinksTarget", () => {
it("treats explicit homepage URLs as page lookups", () => { it("treats explicit homepage URLs as page lookups", () => {
@ -121,18 +128,15 @@ describe("fetchBacklinksSummaryRaw", () => {
{ status: 200, headers: { "Content-Type": "application/json" } }, { status: 200, headers: { "Content-Type": "application/json" } },
), ),
); );
vi.mocked(classifyBacklinksErrorWithAccountState).mockImplementation( classifyBacklinksError.mockImplementation((status: number | undefined) => {
async (status: number | undefined) => {
if (status === 40204) { if (status === 40204) {
return new AppError( return new AppError(
"BACKLINKS_NOT_ENABLED", "BACKLINKS_NOT_ENABLED",
"Backlinks is not enabled", "Backlinks is not enabled",
); );
} }
return null; return null;
}, });
);
await expect( await expect(
fetchBacklinksSummaryRaw({ fetchBacklinksSummaryRaw({
@ -140,7 +144,7 @@ describe("fetchBacklinksSummaryRaw", () => {
}), }),
).rejects.toMatchObject({ code: "BACKLINKS_NOT_ENABLED" }); ).rejects.toMatchObject({ code: "BACKLINKS_NOT_ENABLED" });
expect(classifyBacklinksErrorWithAccountState).toHaveBeenCalledWith( expect(classifyBacklinksError).toHaveBeenCalledWith(
40204, 40204,
expect.stringContaining("Backlinks subscription required"), expect.stringContaining("Backlinks subscription required"),
"/v3/backlinks/summary/live", "/v3/backlinks/summary/live",
@ -164,7 +168,7 @@ describe("fetchBacklinksSummaryRaw", () => {
{ status: 200, headers: { "Content-Type": "application/json" } }, { status: 200, headers: { "Content-Type": "application/json" } },
), ),
); );
vi.mocked(classifyBacklinksErrorWithAccountState).mockResolvedValue(null); classifyBacklinksError.mockReturnValue(null);
await expect( await expect(
fetchBacklinksSummaryRaw({ fetchBacklinksSummaryRaw({
@ -190,7 +194,7 @@ describe("fetchBacklinksSummaryRaw", () => {
{ status: 200, headers: { "Content-Type": "application/json" } }, { status: 200, headers: { "Content-Type": "application/json" } },
), ),
); );
vi.mocked(classifyBacklinksErrorWithAccountState).mockResolvedValue(null); classifyBacklinksError.mockReturnValue(null);
await expect( await expect(
fetchBacklinksSummaryRaw({ fetchBacklinksSummaryRaw({
@ -233,7 +237,7 @@ describe("fetchBacklinksSummaryRaw", () => {
{ status: 200, headers: { "Content-Type": "application/json" } }, { status: 200, headers: { "Content-Type": "application/json" } },
), ),
); );
vi.mocked(classifyBacklinksErrorWithAccountState).mockResolvedValue(null); classifyBacklinksError.mockReturnValue(null);
await expect( await expect(
fetchBacklinksRowsRaw({ fetchBacklinksRowsRaw({

View File

@ -9,6 +9,7 @@ import type {
} from "@/server/lib/dataforseoCost"; } from "@/server/lib/dataforseoCost";
import { getRequiredEnvValue } from "@/server/lib/runtime-env"; import { getRequiredEnvValue } from "@/server/lib/runtime-env";
import { import {
classifyBacklinksError,
type BacklinksTaskResult, type BacklinksTaskResult,
backlinksHistoryItemSchema, backlinksHistoryItemSchema,
backlinksItemSchema, backlinksItemSchema,
@ -18,7 +19,6 @@ import {
referringDomainItemSchema, referringDomainItemSchema,
responseSchema, responseSchema,
} from "@/server/lib/dataforseoBacklinksSupport"; } from "@/server/lib/dataforseoBacklinksSupport";
import { classifyBacklinksErrorWithAccountState } from "@/server/lib/dataforseoBacklinksAccount";
export { normalizeBacklinksTarget } from "@/server/lib/dataforseoBacklinksTarget"; export { normalizeBacklinksTarget } from "@/server/lib/dataforseoBacklinksTarget";
const API_BASE = "https://api.dataforseo.com"; const API_BASE = "https://api.dataforseo.com";
@ -69,7 +69,7 @@ async function postBacklinks(path: string, payload: unknown) {
const rawText = await response.text(); const rawText = await response.text();
if (!response.ok) { if (!response.ok) {
const classifiedError = await classifyBacklinksErrorWithAccountState( const classifiedError = classifyBacklinksError(
response.status, response.status,
rawText, rawText,
path, path,
@ -85,7 +85,7 @@ async function postBacklinks(path: string, payload: unknown) {
try { try {
raw = JSON.parse(rawText); raw = JSON.parse(rawText);
} catch { } catch {
const classifiedError = await classifyBacklinksErrorWithAccountState( const classifiedError = classifyBacklinksError(
response.status, response.status,
rawText, rawText,
path, path,
@ -103,7 +103,7 @@ async function postBacklinks(path: string, payload: unknown) {
const parsed = responseSchema.safeParse(raw); const parsed = responseSchema.safeParse(raw);
if (!parsed.success) { if (!parsed.success) {
const classifiedError = await classifyBacklinksErrorWithAccountState( const classifiedError = classifyBacklinksError(
response.status, response.status,
rawText, rawText,
path, path,
@ -121,7 +121,7 @@ async function postBacklinks(path: string, payload: unknown) {
const responseData = parsed.data; const responseData = parsed.data;
if (responseData.status_code !== 20000) { if (responseData.status_code !== 20000) {
const classifiedError = await classifyBacklinksErrorWithAccountState( const classifiedError = classifyBacklinksError(
responseData.status_code, responseData.status_code,
`${responseData.status_message ?? ""} ${rawText}`, `${responseData.status_message ?? ""} ${rawText}`,
path, path,
@ -139,7 +139,7 @@ async function postBacklinks(path: string, payload: unknown) {
} }
if (task.status_code !== 20000) { if (task.status_code !== 20000) {
const classifiedError = await classifyBacklinksErrorWithAccountState( const classifiedError = classifyBacklinksError(
task.status_code, task.status_code,
`${task.status_message ?? ""} ${rawText}`, `${task.status_message ?? ""} ${rawText}`,
path, path,

View File

@ -1,154 +0,0 @@
import { z } from "zod";
import { AppError } from "@/server/lib/errors";
import { classifyBacklinksError } from "@/server/lib/dataforseoBacklinksSupport";
import { getRequiredEnvValue } from "@/server/lib/runtime-env";
const API_BASE = "https://api.dataforseo.com";
const userDataResponseSchema = z
.object({
status_code: z.number().optional(),
status_message: z.string().optional(),
tasks: z
.array(
z
.object({
status_code: z.number().optional(),
status_message: z.string().optional(),
result: z
.array(
z
.object({
money: z
.object({
balance: z.number().nullable().optional(),
})
.passthrough()
.optional(),
backlinks_subscription_expiry_date: z
.string()
.nullable()
.optional(),
})
.passthrough(),
)
.nullable()
.optional(),
})
.passthrough(),
)
.optional(),
})
.passthrough();
async function createAuthenticatedFetch() {
const apiKey = await getRequiredEnvValue("DATAFORSEO_API_KEY");
return (url: RequestInfo, init?: RequestInit): Promise<Response> => {
const headers = new Headers(init?.headers);
headers.set("Authorization", `Basic ${apiKey}`);
return fetch(url, {
...init,
headers,
});
};
}
async function getDataforseo(path: string) {
const authenticatedFetch = await createAuthenticatedFetch();
const response = await authenticatedFetch(`${API_BASE}${path}`, {
method: "GET",
});
if (!response.ok) {
throw new AppError(
"INTERNAL_ERROR",
`DataForSEO HTTP ${response.status} on ${path}`,
);
}
return await response.json();
}
async function fetchBacklinksAccountState() {
const raw = await getDataforseo("/v3/appendix/user_data");
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 {
balance: result.money?.balance ?? null,
backlinksSubscriptionExpiryDate:
result.backlinks_subscription_expiry_date ?? null,
};
}
function hasActiveBacklinksSubscription(value: string | null) {
if (!value) return false;
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return true;
return parsed.getTime() > Date.now();
}
export async function classifyBacklinksErrorWithAccountState(
status: number | undefined,
details: string,
path: string,
) {
const classifiedError = classifyBacklinksError(status, details, path);
if (classifiedError) {
return classifiedError;
}
const text = details.toLowerCase();
const needsAccountLookup =
path.includes("/backlinks/") &&
(status === 402 ||
status === 403 ||
text.includes("backlinks") ||
text.includes("subscription") ||
text.includes("billing") ||
text.includes("balance") ||
text.includes("payment"));
if (!needsAccountLookup) {
return null;
}
const accountState = await fetchBacklinksAccountState().catch(() => null);
if (!accountState) {
return null;
}
if (
!hasActiveBacklinksSubscription(
accountState.backlinksSubscriptionExpiryDate,
)
) {
return new AppError(
"BACKLINKS_NOT_ENABLED",
"Backlinks is not enabled for the connected DataForSEO account",
);
}
if (typeof accountState.balance === "number" && accountState.balance <= 0) {
return new AppError(
"BACKLINKS_BILLING_ISSUE",
"The connected DataForSEO account has a billing or balance issue",
);
}
return null;
}

View File

@ -1,4 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { createDataforseoAccessClassifier } from "@/server/lib/dataforseoAccessClassification";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
const taskResultSchema = z const taskResultSchema = z
@ -120,88 +121,15 @@ export const backlinksHistoryItemSchema = z
}) })
.passthrough(); .passthrough();
export function classifyBacklinksError( export const classifyBacklinksError = createDataforseoAccessClassifier({
status: number | undefined, pathPrefix: "/backlinks/",
details: string, notEnabledCode: "BACKLINKS_NOT_ENABLED",
path: string, notEnabledMessage:
): AppError | null {
const text = details.toLowerCase();
const looksLikeBacklinksAccessIssue =
path.includes("/backlinks/") &&
(text.includes("backlinks") ||
text.includes("subscription") ||
text.includes("access") ||
text.includes("plan") ||
text.includes("balance") ||
text.includes("payment") ||
text.includes("billing") ||
text.includes("available") ||
text.includes("enabled") ||
status === 402 ||
status === 403);
if (!looksLikeBacklinksAccessIssue) return null;
if (status === 40204) {
return new AppError(
"BACKLINKS_NOT_ENABLED",
"Backlinks is not enabled for the connected DataForSEO account", "Backlinks is not enabled for the connected DataForSEO account",
); billingIssueCode: "BACKLINKS_BILLING_ISSUE",
} billingIssueMessage:
if (status === 40200 || status === 40210 || status === 402) {
return new AppError(
"BACKLINKS_BILLING_ISSUE",
"The connected DataForSEO account has a billing or balance issue", "The connected DataForSEO account has a billing or balance issue",
); });
}
const unavailableSignals = [
"not available",
"not enabled",
"not allowed",
"access denied",
"forbidden",
"insufficient",
"subscription",
"upgrade",
"plan",
"activate your subscription",
"plans and subscriptions",
];
const billingSignals = [
"payment required",
"billing",
"balance",
"insufficient funds",
"balance is too low",
"problem billing",
"recharged",
];
if (billingSignals.some((signal) => text.includes(signal))) {
return new AppError(
"BACKLINKS_BILLING_ISSUE",
"The connected DataForSEO account has a billing or balance issue",
);
}
if (unavailableSignals.some((signal) => text.includes(signal))) {
return new AppError(
"BACKLINKS_NOT_ENABLED",
"Backlinks is not enabled for the connected DataForSEO account",
);
}
if (status === 403) {
return new AppError(
"BACKLINKS_NOT_ENABLED",
"Backlinks is not enabled for the connected DataForSEO account",
);
}
return null;
}
export function parseItems<T extends z.ZodTypeAny>( export function parseItems<T extends z.ZodTypeAny>(
endpointName: string, endpointName: string,

View File

@ -12,6 +12,7 @@ import {
type LlmTopPagesItem, type LlmTopPagesItem,
} from "@/server/lib/dataforseoLlmSchemas"; } from "@/server/lib/dataforseoLlmSchemas";
import type { DataforseoApiResponse } from "@/server/lib/dataforseoCost"; import type { DataforseoApiResponse } from "@/server/lib/dataforseoCost";
import { createDataforseoAccessClassifier } from "@/server/lib/dataforseoAccessClassification";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import { getRequiredEnvValue } from "@/server/lib/runtime-env"; import { getRequiredEnvValue } from "@/server/lib/runtime-env";
@ -50,9 +51,12 @@ async function postLlm(path: string, payload: unknown): Promise<unknown> {
const rawText = await response.text(); const rawText = await response.text();
if (!response.ok) { if (!response.ok) {
throw new AppError( throw (
classifyAiSearchError(response.status, rawText, path) ??
new AppError(
"INTERNAL_ERROR", "INTERNAL_ERROR",
`DataForSEO HTTP ${response.status} on ${path}. Response: ${truncate(rawText)}`, `DataForSEO HTTP ${response.status} on ${path}. Response: ${truncate(rawText)}`,
)
); );
} }
@ -66,6 +70,16 @@ async function postLlm(path: string, payload: unknown): Promise<unknown> {
} }
} }
const classifyAiSearchError = createDataforseoAccessClassifier({
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",
});
function truncate(text: string): string { function truncate(text: string): string {
return text.length > MAX_DATAFORSEO_ERROR_PAYLOAD_LENGTH return text.length > MAX_DATAFORSEO_ERROR_PAYLOAD_LENGTH
? `${text.slice(0, MAX_DATAFORSEO_ERROR_PAYLOAD_LENGTH)}... [truncated]` ? `${text.slice(0, MAX_DATAFORSEO_ERROR_PAYLOAD_LENGTH)}... [truncated]`
@ -86,9 +100,10 @@ function parseEnvelope(path: string, raw: unknown): LlmDataforseoTask {
const data = envelope.data; const data = envelope.data;
if (data.status_code !== 20000) { if (data.status_code !== 20000) {
throw new AppError( const message = data.status_message || `DataForSEO ${path} request failed`;
"INTERNAL_ERROR", throw (
data.status_message || `DataForSEO ${path} request failed`, classifyAiSearchError(data.status_code, message, path) ??
new AppError("INTERNAL_ERROR", message)
); );
} }
@ -101,9 +116,10 @@ function parseEnvelope(path: string, raw: unknown): LlmDataforseoTask {
} }
if (task.status_code !== 20000) { if (task.status_code !== 20000) {
throw new AppError( const message = task.status_message || `DataForSEO ${path} task failed`;
"INTERNAL_ERROR", throw (
task.status_message || `DataForSEO ${path} task failed`, classifyAiSearchError(task.status_code, message, path) ??
new AppError("INTERNAL_ERROR", message)
); );
} }

View File

@ -26,15 +26,6 @@ export async function isHostedServerAuthMode(): Promise<boolean> {
return isHostedAuthMode(await getEnvValue("AUTH_MODE")); return isHostedAuthMode(await getEnvValue("AUTH_MODE"));
} }
export async function getWorkersBinding(name: string): Promise<unknown> {
const workersEnv = await getWorkersEnv();
const binding = workersEnv?.[name];
if (!binding) {
throw new Error(`Missing required Worker binding: ${name}`);
}
return binding;
}
async function getWorkersEnv(): Promise<Record<string, unknown> | null> { async function getWorkersEnv(): Promise<Record<string, unknown> | null> {
if (!workersEnvPromise) { if (!workersEnvPromise) {
workersEnvPromise = loadWorkersEnv(); workersEnvPromise = loadWorkersEnv();

View File

@ -0,0 +1,34 @@
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)
.inputValidator((data: unknown) => aiSearchProjectSchema.parse(data))
.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,10 +1,5 @@
import { createServerFn } from "@tanstack/react-start"; import { createServerFn } from "@tanstack/react-start";
import {
buildBacklinksDisabledAccessStatus,
setBacklinksAccessStatus,
} from "@/server/features/backlinks/backlinksAccess";
import { BacklinksService } from "@/server/features/backlinks/services/BacklinksService"; import { BacklinksService } from "@/server/features/backlinks/services/BacklinksService";
import { AppError } from "@/server/lib/errors";
import { requireProjectContext } from "@/serverFunctions/middleware"; import { requireProjectContext } from "@/serverFunctions/middleware";
import { backlinksOverviewInputSchema } from "@/types/schemas/backlinks"; import { backlinksOverviewInputSchema } from "@/types/schemas/backlinks";
@ -14,7 +9,6 @@ export const getBacklinksOverview = createServerFn({
.middleware(requireProjectContext) .middleware(requireProjectContext)
.inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data)) .inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data))
.handler(async ({ data, context }) => { .handler(async ({ data, context }) => {
try {
const input = { const input = {
target: data.target, target: data.target,
scope: data.scope, scope: data.scope,
@ -29,16 +23,6 @@ export const getBacklinksOverview = createServerFn({
spamOptions, spamOptions,
); );
return profile.overview; return profile.overview;
} catch (error) {
if (error instanceof AppError && error.code === "BACKLINKS_NOT_ENABLED") {
const checkedAt = new Date().toISOString();
await setBacklinksAccessStatus(
buildBacklinksDisabledAccessStatus(checkedAt, error.code),
);
}
throw error;
}
}); });
export const getBacklinksReferringDomains = createServerFn({ export const getBacklinksReferringDomains = createServerFn({
@ -47,7 +31,6 @@ export const getBacklinksReferringDomains = createServerFn({
.middleware(requireProjectContext) .middleware(requireProjectContext)
.inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data)) .inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data))
.handler(async ({ data, context }) => { .handler(async ({ data, context }) => {
try {
const input = { const input = {
target: data.target, target: data.target,
scope: data.scope, scope: data.scope,
@ -57,10 +40,6 @@ export const getBacklinksReferringDomains = createServerFn({
context, context,
); );
return profile.rows; return profile.rows;
} catch (error) {
await updateBacklinksAccessStatusOnError(error);
throw error;
}
}); });
export const getBacklinksTopPages = createServerFn({ export const getBacklinksTopPages = createServerFn({
@ -69,24 +48,10 @@ export const getBacklinksTopPages = createServerFn({
.middleware(requireProjectContext) .middleware(requireProjectContext)
.inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data)) .inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data))
.handler(async ({ data, context }) => { .handler(async ({ data, context }) => {
try {
const input = { const input = {
target: data.target, target: data.target,
scope: data.scope, scope: data.scope,
}; };
const profile = await BacklinksService.profileTopPages(input, context); const profile = await BacklinksService.profileTopPages(input, context);
return profile.rows; return profile.rows;
} catch (error) {
await updateBacklinksAccessStatusOnError(error);
throw error;
}
}); });
async function updateBacklinksAccessStatusOnError(error: unknown) {
if (error instanceof AppError && error.code === "BACKLINKS_NOT_ENABLED") {
const checkedAt = new Date().toISOString();
await setBacklinksAccessStatus(
buildBacklinksDisabledAccessStatus(checkedAt, error.code),
);
}
}

View File

@ -1,78 +1,34 @@
import { createServerFn } from "@tanstack/react-start"; import { createServerFn } from "@tanstack/react-start";
import { import {
buildBacklinksDisabledAccessStatus, fetchDataforseoAccountState,
buildVerifiedBacklinksAccessStatus, hasActiveDataforseoSubscription,
getBacklinksAccessStatus, } from "@/server/lib/dataforseoAccountState";
setBacklinksAccessStatus,
} from "@/server/features/backlinks/backlinksAccess";
import { AppError } from "@/server/lib/errors";
import { createDataforseoClient } from "@/server/lib/dataforseoClient";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
import { requireProjectContext } from "@/serverFunctions/middleware"; import { requireProjectContext } from "@/serverFunctions/middleware";
import { backlinksProjectSchema } from "@/types/schemas/backlinks"; import { backlinksProjectSchema } from "@/types/schemas/backlinks";
const BACKLINKS_ACCESS_CHECK_COOLDOWN_MS = 15 * 60 * 1000; const BACKLINKS_NOT_ENABLED_MESSAGE =
"Backlinks is not enabled for the connected DataForSEO account yet. Turn it on in DataForSEO, then confirm here.";
export const getBacklinksAccessSetupStatus = createServerFn({ type BacklinksAccessStatus = {
method: "GET", enabled: boolean;
}) errorMessage: string | null;
};
export const getBacklinksAccessSetupStatus = createServerFn({ method: "GET" })
.middleware(requireProjectContext) .middleware(requireProjectContext)
.inputValidator((data: unknown) => backlinksProjectSchema.parse(data)) .inputValidator((data: unknown) => backlinksProjectSchema.parse(data))
.handler(async () => getBacklinksAccessStatus()); .handler(async (): Promise<BacklinksAccessStatus> => {
export const testBacklinksAccess = createServerFn({
method: "POST",
})
.middleware(requireProjectContext)
.inputValidator((data: unknown) => backlinksProjectSchema.parse(data))
.handler(async ({ context }) => {
if (await isHostedServerAuthMode()) { if (await isHostedServerAuthMode()) {
// Hosted deployments do not run the manual DataForSEO access test here; return { enabled: true, errorMessage: null };
// backlinks access is treated as platform-managed in this mode.
return getBacklinksAccessStatus();
} }
const cachedStatus = await getBacklinksAccessStatus(); const state = await fetchDataforseoAccountState();
if (isRecentVerifiedBacklinksAccessCheck(cachedStatus)) { const enabled = hasActiveDataforseoSubscription(
return cachedStatus; state?.backlinksSubscriptionExpiryDate ?? null,
}
const checkedAt = new Date().toISOString();
const dataforseo = createDataforseoClient(context);
try {
await dataforseo.backlinks.summary({
target: "dataforseo.com",
});
const status = buildVerifiedBacklinksAccessStatus(checkedAt);
await setBacklinksAccessStatus(status);
return status;
} catch (error) {
if (error instanceof AppError && error.code === "BACKLINKS_NOT_ENABLED") {
const status = buildBacklinksDisabledAccessStatus(
checkedAt,
error.code,
); );
await setBacklinksAccessStatus(status); return {
return status; enabled,
} errorMessage: enabled ? null : BACKLINKS_NOT_ENABLED_MESSAGE,
};
throw error;
}
}); });
function isRecentVerifiedBacklinksAccessCheck(
status: Awaited<ReturnType<typeof getBacklinksAccessStatus>>,
) {
if (!status.enabled || !status.lastCheckedAt) {
return false;
}
const lastChecked = Date.parse(status.lastCheckedAt);
if (Number.isNaN(lastChecked)) {
return false;
}
return Date.now() - lastChecked < BACKLINKS_ACCESS_CHECK_COOLDOWN_MS;
}

View File

@ -12,6 +12,8 @@ const ERROR_CODES = [
"CRAWL_TARGET_BLOCKED", "CRAWL_TARGET_BLOCKED",
"BACKLINKS_NOT_ENABLED", "BACKLINKS_NOT_ENABLED",
"BACKLINKS_BILLING_ISSUE", "BACKLINKS_BILLING_ISSUE",
"AI_SEARCH_NOT_ENABLED",
"AI_SEARCH_BILLING_ISSUE",
"RATE_LIMITED", "RATE_LIMITED",
"CONFLICT", "CONFLICT",
"INTERNAL_ERROR", "INTERNAL_ERROR",

View File

@ -7,6 +7,14 @@ 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
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------