Remove backlinks + LLM-mentions access gates
This commit is contained in:
parent
3c7e704b4a
commit
c3caf2009f
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -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."
|
||||||
|
|||||||
@ -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."
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -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}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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",
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -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);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@ -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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -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),
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user