feat: gate llm mentions in self hosted product (#134)
This commit is contained in:
parent
65d62f0eed
commit
1108cdd9a7
83
src/client/features/access-gate/AccessGate.tsx
Normal file
83
src/client/features/access-gate/AccessGate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/client/features/access-gate/useAccessGate.ts
Normal file
46
src/client/features/access-gate/useAccessGate.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -1,6 +1,5 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AutumnProvider, useCustomer } from "autumn-js/react";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
@ -9,14 +8,21 @@ import {
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import { lookupBrand } from "@/serverFunctions/ai-search";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
|
||||
import {
|
||||
HostedPlanGate,
|
||||
type HostedPlanGateState,
|
||||
} from "@/client/features/billing/HostedPlanGate";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import { BrandLookupResults } from "@/client/features/ai-search/components/BrandLookupResults";
|
||||
import { BrandLookupSearchCard } from "@/client/features/ai-search/components/BrandLookupSearchCard";
|
||||
import { BrandLookupHistorySection } from "@/client/features/ai-search/components/BrandLookupHistorySection";
|
||||
import { AiSearchLoadingState } from "@/client/features/ai-search/components/AiSearchLoadingState";
|
||||
import { AiSearchPaidPlanGate } from "@/client/features/ai-search/components/AiSearchPaidPlanGate";
|
||||
import {
|
||||
AiSearchAccessLoadingState,
|
||||
AiSearchSetupGate,
|
||||
} from "@/client/features/ai-search/components/AiSearchSetupGate";
|
||||
import { useAiSearchAccess } from "@/client/features/ai-search/useAiSearchAccess";
|
||||
import { useBrandLookupSearchHistory } from "@/client/hooks/useBrandLookupSearchHistory";
|
||||
import { BRAND_LOOKUP_MAX_INPUT_LENGTH } from "@/types/schemas/ai-search";
|
||||
|
||||
@ -46,9 +52,9 @@ const BRAND_LOOKUP_BULLETS = [
|
||||
|
||||
export function BrandLookupPage(props: Props) {
|
||||
return (
|
||||
<AutumnProvider>
|
||||
<BrandLookupPageInner {...props} />
|
||||
</AutumnProvider>
|
||||
<HostedPlanGate>
|
||||
{(planGate) => <BrandLookupPageInner {...props} planGate={planGate} />}
|
||||
</HostedPlanGate>
|
||||
);
|
||||
}
|
||||
|
||||
@ -56,18 +62,12 @@ function BrandLookupPageInner({
|
||||
projectId,
|
||||
initialQuery,
|
||||
onQueryChange,
|
||||
}: Props) {
|
||||
planGate,
|
||||
}: Props & { planGate: HostedPlanGateState }) {
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
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 access = useAiSearchAccess(projectId);
|
||||
|
||||
const trimmedInitialQuery = initialQuery.trim();
|
||||
const hasActiveQuery = trimmedInitialQuery.length > 0;
|
||||
@ -83,7 +83,7 @@ function BrandLookupPageInner({
|
||||
languageCode: "en",
|
||||
},
|
||||
}),
|
||||
enabled: hasActiveQuery && !isFreePlan,
|
||||
enabled: hasActiveQuery && !planGate.isFreePlan && access.enabled,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
@ -137,7 +137,7 @@ function BrandLookupPageInner({
|
||||
: null;
|
||||
const resultData = hasActiveQuery ? lookupQuery.data : undefined;
|
||||
|
||||
if (!planKnown) return null;
|
||||
if (planGate.isLoading) return null;
|
||||
|
||||
return (
|
||||
<div className="px-4 py-4 pb-24 overflow-auto md:px-6 md:py-6 md:pb-8">
|
||||
@ -149,7 +149,15 @@ function BrandLookupPageInner({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isFreePlan ? (
|
||||
{access.isLoading ? (
|
||||
<AiSearchAccessLoadingState />
|
||||
) : !access.enabled ? (
|
||||
<AiSearchSetupGate
|
||||
errorMessage={access.errorMessage ?? access.statusErrorMessage}
|
||||
isRefetching={access.isRefetching}
|
||||
onRetry={access.onRetry}
|
||||
/>
|
||||
) : planGate.isFreePlan ? (
|
||||
<AiSearchPaidPlanGate
|
||||
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."
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AutumnProvider, useCustomer } from "autumn-js/react";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
@ -9,14 +8,21 @@ import {
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { explorePrompt } from "@/serverFunctions/ai-search";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
|
||||
import {
|
||||
HostedPlanGate,
|
||||
type HostedPlanGateState,
|
||||
} from "@/client/features/billing/HostedPlanGate";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import { PromptExplorerForm } from "@/client/features/ai-search/components/PromptExplorerForm";
|
||||
import { PromptExplorerResults } from "@/client/features/ai-search/components/PromptExplorerResults";
|
||||
import { PromptExplorerLoadingState } from "@/client/features/ai-search/components/PromptExplorerLoadingState";
|
||||
import { PromptExplorerHistorySection } from "@/client/features/ai-search/components/PromptExplorerHistorySection";
|
||||
import { AiSearchPaidPlanGate } from "@/client/features/ai-search/components/AiSearchPaidPlanGate";
|
||||
import {
|
||||
AiSearchAccessLoadingState,
|
||||
AiSearchSetupGate,
|
||||
} from "@/client/features/ai-search/components/AiSearchSetupGate";
|
||||
import { useAiSearchAccess } from "@/client/features/ai-search/useAiSearchAccess";
|
||||
import {
|
||||
usePromptExplorerSearchHistory,
|
||||
type PromptExplorerSearchHistoryItem,
|
||||
@ -68,24 +74,19 @@ const INITIAL_FORM_STATE: FormState = {
|
||||
|
||||
export function PromptExplorerPage(props: Props) {
|
||||
return (
|
||||
<AutumnProvider>
|
||||
<PromptExplorerPageInner {...props} />
|
||||
</AutumnProvider>
|
||||
<HostedPlanGate>
|
||||
{(planGate) => <PromptExplorerPageInner {...props} planGate={planGate} />}
|
||||
</HostedPlanGate>
|
||||
);
|
||||
}
|
||||
|
||||
function PromptExplorerPageInner({ projectId }: Props) {
|
||||
function PromptExplorerPageInner({
|
||||
projectId,
|
||||
planGate,
|
||||
}: Props & { planGate: HostedPlanGateState }) {
|
||||
const [form, setForm] = useState<FormState>(INITIAL_FORM_STATE);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
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 access = useAiSearchAccess(projectId);
|
||||
|
||||
const {
|
||||
history,
|
||||
@ -177,7 +178,7 @@ function PromptExplorerPageInner({ projectId }: Props) {
|
||||
if (validationError) setValidationError(null);
|
||||
};
|
||||
|
||||
if (!planKnown) return null;
|
||||
if (planGate.isLoading) return null;
|
||||
|
||||
return (
|
||||
<div className="px-4 py-4 pb-24 overflow-auto md:px-6 md:py-6 md:pb-8">
|
||||
@ -190,7 +191,15 @@ function PromptExplorerPageInner({ projectId }: Props) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isFreePlan ? (
|
||||
{access.isLoading ? (
|
||||
<AiSearchAccessLoadingState />
|
||||
) : !access.enabled ? (
|
||||
<AiSearchSetupGate
|
||||
errorMessage={access.errorMessage ?? access.statusErrorMessage}
|
||||
isRefetching={access.isRefetching}
|
||||
onRetry={access.onRetry}
|
||||
/>
|
||||
) : planGate.isFreePlan ? (
|
||||
<AiSearchPaidPlanGate
|
||||
feature="Prompt Explorer"
|
||||
description="Ask one prompt across ChatGPT, Claude, Gemini, and Perplexity at the same time and compare their answers — including which sources each model cites."
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
10
src/client/features/ai-search/useAiSearchAccess.ts
Normal file
10
src/client/features/ai-search/useAiSearchAccess.ts
Normal 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.",
|
||||
});
|
||||
}
|
||||
@ -9,7 +9,6 @@ import {
|
||||
} from "./useBacklinksPageData";
|
||||
import { useBacklinksFilters } from "./useBacklinksFilters";
|
||||
import { useBacklinksSearchHistory } from "@/client/hooks/useBacklinksSearchHistory";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
|
||||
export function BacklinksPage({
|
||||
projectId,
|
||||
@ -18,17 +17,13 @@ export function BacklinksPage({
|
||||
}: BacklinksPageProps) {
|
||||
const filters = useBacklinksFilters();
|
||||
const {
|
||||
accessStatus,
|
||||
accessStatusErrorMessage,
|
||||
accessStatusQuery,
|
||||
accessGate,
|
||||
activeTabErrorMessage,
|
||||
backlinksDisabledByError,
|
||||
backlinksEnabled,
|
||||
overviewErrorMessage,
|
||||
overviewQuery,
|
||||
referringDomainsQuery,
|
||||
searchCardInitialValues,
|
||||
testAccessMutation,
|
||||
topPagesQuery,
|
||||
} = useBacklinksPageData({
|
||||
projectId,
|
||||
@ -63,8 +58,8 @@ export function BacklinksPage({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!accessStatusQuery.isLoading &&
|
||||
backlinksEnabled &&
|
||||
{!accessGate.isLoading &&
|
||||
accessGate.enabled &&
|
||||
!backlinksDisabledByError ? (
|
||||
<BacklinksSearchCard
|
||||
errorMessage={overviewErrorMessage}
|
||||
@ -82,13 +77,10 @@ export function BacklinksPage({
|
||||
) : null}
|
||||
|
||||
<BacklinksBody
|
||||
accessStatus={accessStatus}
|
||||
accessStatusError={accessStatusErrorMessage}
|
||||
accessGate={accessGate}
|
||||
backlinksDisabledByError={backlinksDisabledByError}
|
||||
backlinksEnabled={backlinksEnabled}
|
||||
history={history}
|
||||
historyLoaded={historyLoaded}
|
||||
isAccessStatusLoading={accessStatusQuery.isLoading}
|
||||
overviewData={overviewQuery.data}
|
||||
overviewError={overviewErrorMessage}
|
||||
overviewLoading={overviewQuery.isLoading}
|
||||
@ -101,23 +93,12 @@ export function BacklinksPage({
|
||||
referringDomainsQuery.isLoading) ||
|
||||
(searchState.tab === "pages" && topPagesQuery.isLoading)
|
||||
}
|
||||
testError={
|
||||
testAccessMutation.error
|
||||
? getStandardErrorMessage(
|
||||
testAccessMutation.error,
|
||||
"Could not test Backlinks access.",
|
||||
)
|
||||
: null
|
||||
}
|
||||
testIsPending={testAccessMutation.isPending}
|
||||
topPages={topPagesQuery.data}
|
||||
onRemoveHistoryItem={removeHistoryItem}
|
||||
onRetryAccess={() => void accessStatusQuery.refetch()}
|
||||
onSelectHistoryItem={handleHistorySelect}
|
||||
onShowHistory={() => navigateToBacklinksHistory(navigate)}
|
||||
onSetActiveTab={(tab) => navigateToBacklinksTab(navigate, tab)}
|
||||
onRetryOverview={() => void overviewQuery.refetch()}
|
||||
onTestAccess={() => testAccessMutation.mutate()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -12,12 +12,12 @@ import {
|
||||
import { BacklinksHistorySection } from "./BacklinksHistorySection";
|
||||
import type { BacklinksSearchHistoryItem } from "@/client/hooks/useBacklinksSearchHistory";
|
||||
import type {
|
||||
BacklinksAccessStatusData,
|
||||
BacklinksOverviewData,
|
||||
BacklinksReferringDomainsData,
|
||||
BacklinksSearchState,
|
||||
BacklinksTopPagesData,
|
||||
} from "./backlinksPageTypes";
|
||||
import type { UseAccessGateResult } from "@/client/features/access-gate/useAccessGate";
|
||||
import { buildSummaryStats } from "./backlinksPageUtils";
|
||||
import {
|
||||
filterBacklinkRows,
|
||||
@ -27,13 +27,10 @@ import {
|
||||
import type { BacklinksFiltersState } from "./useBacklinksFilters";
|
||||
|
||||
type BacklinksBodyProps = {
|
||||
accessStatus: BacklinksAccessStatusData | undefined;
|
||||
accessStatusError: string | null;
|
||||
accessGate: UseAccessGateResult;
|
||||
backlinksDisabledByError: boolean;
|
||||
backlinksEnabled: boolean;
|
||||
history: BacklinksSearchHistoryItem[];
|
||||
historyLoaded: boolean;
|
||||
isAccessStatusLoading: boolean;
|
||||
overviewData: BacklinksOverviewData | undefined;
|
||||
overviewError: string | null;
|
||||
overviewLoading: boolean;
|
||||
@ -42,26 +39,19 @@ type BacklinksBodyProps = {
|
||||
filters: BacklinksFiltersState;
|
||||
tabErrorMessage: string | null;
|
||||
tabLoading: boolean;
|
||||
testError: string | null;
|
||||
testIsPending: boolean;
|
||||
topPages: BacklinksTopPagesData | undefined;
|
||||
onRemoveHistoryItem: (timestamp: number) => void;
|
||||
onRetryAccess: () => void;
|
||||
onSelectHistoryItem: (item: BacklinksSearchHistoryItem) => void;
|
||||
onShowHistory: () => void;
|
||||
onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void;
|
||||
onRetryOverview: () => void;
|
||||
onTestAccess: () => void;
|
||||
};
|
||||
|
||||
export function BacklinksBody({
|
||||
accessStatus,
|
||||
accessStatusError,
|
||||
accessGate,
|
||||
backlinksDisabledByError,
|
||||
backlinksEnabled,
|
||||
history,
|
||||
historyLoaded,
|
||||
isAccessStatusLoading,
|
||||
overviewData,
|
||||
overviewError,
|
||||
overviewLoading,
|
||||
@ -70,16 +60,12 @@ export function BacklinksBody({
|
||||
filters,
|
||||
tabErrorMessage,
|
||||
tabLoading,
|
||||
testError,
|
||||
testIsPending,
|
||||
topPages,
|
||||
onRemoveHistoryItem,
|
||||
onRetryAccess,
|
||||
onSelectHistoryItem,
|
||||
onShowHistory,
|
||||
onSetActiveTab,
|
||||
onRetryOverview,
|
||||
onTestAccess,
|
||||
}: BacklinksBodyProps) {
|
||||
const mergedData = useMemo(
|
||||
() => mergeTabData(overviewData, referringDomains, topPages),
|
||||
@ -111,26 +97,25 @@ export function BacklinksBody({
|
||||
[mergedData],
|
||||
);
|
||||
|
||||
if (isAccessStatusLoading) {
|
||||
if (accessGate.isLoading) {
|
||||
return <BacklinksAccessLoadingState />;
|
||||
}
|
||||
|
||||
if (accessStatusError) {
|
||||
if (accessGate.statusErrorMessage) {
|
||||
return (
|
||||
<BacklinksErrorState
|
||||
errorMessage={accessStatusError}
|
||||
onRetry={onRetryAccess}
|
||||
errorMessage={accessGate.statusErrorMessage}
|
||||
onRetry={accessGate.onRetry}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!backlinksEnabled || backlinksDisabledByError) {
|
||||
if (!accessGate.enabled || backlinksDisabledByError) {
|
||||
return (
|
||||
<BacklinksSetupGate
|
||||
status={accessStatus}
|
||||
isTesting={testIsPending}
|
||||
testError={testError}
|
||||
onTest={onTestAccess}
|
||||
errorMessage={accessGate.errorMessage}
|
||||
isRefetching={accessGate.isRefetching}
|
||||
onRetry={accessGate.onRetry}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,73 +1,40 @@
|
||||
import { ShieldAlert, Wrench } from "lucide-react";
|
||||
import type { BacklinksAccessStatusData } from "./backlinksPageTypes";
|
||||
import { formatRelativeTimestamp } from "./backlinksPageUtils";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import {
|
||||
AccessGate,
|
||||
AccessGateLoadingState,
|
||||
} from "@/client/features/access-gate/AccessGate";
|
||||
|
||||
export function BacklinksAccessLoadingState() {
|
||||
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>
|
||||
);
|
||||
return <AccessGateLoadingState />;
|
||||
}
|
||||
|
||||
export function BacklinksSetupGate({
|
||||
status,
|
||||
isTesting,
|
||||
testError,
|
||||
onTest,
|
||||
errorMessage,
|
||||
isRefetching,
|
||||
onRetry,
|
||||
}: {
|
||||
status: BacklinksAccessStatusData | undefined;
|
||||
isTesting: boolean;
|
||||
testError: string | null;
|
||||
onTest: () => void;
|
||||
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">Enable Backlinks</h2>
|
||||
<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.
|
||||
</p>
|
||||
<p className="text-xs text-base-content/50">
|
||||
DataForSEO offers a free 14-day trial for Backlinks. Then, it's
|
||||
$100/month. We're gauging interest in building out a lower-cost
|
||||
alternative, <InlineMailingListLink /> if you're interested.
|
||||
</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>
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -131,45 +98,15 @@ export function BacklinksErrorState({
|
||||
);
|
||||
}
|
||||
|
||||
function BacklinksSetupFeedback({
|
||||
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() {
|
||||
function InlineManagedOpenSeoLink() {
|
||||
return (
|
||||
<a
|
||||
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"
|
||||
rel="noreferrer"
|
||||
>
|
||||
join the OpenSEO mailing list
|
||||
use managed OpenSEO
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,14 +7,10 @@ import type {
|
||||
getBacklinksReferringDomains,
|
||||
getBacklinksTopPages,
|
||||
} from "@/serverFunctions/backlinks";
|
||||
import type { getBacklinksAccessSetupStatus } from "@/serverFunctions/backlinksAccess";
|
||||
|
||||
export type BacklinksOverviewData = Awaited<
|
||||
ReturnType<typeof getBacklinksOverview>
|
||||
>;
|
||||
export type BacklinksAccessStatusData = Awaited<
|
||||
ReturnType<typeof getBacklinksAccessSetupStatus>
|
||||
>;
|
||||
export type BacklinksReferringDomainsData = Awaited<
|
||||
ReturnType<typeof getBacklinksReferringDomains>
|
||||
>;
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type {
|
||||
BacklinksPageProps,
|
||||
BacklinksSearchState,
|
||||
} from "./backlinksPageTypes";
|
||||
import { useAccessGate } from "@/client/features/access-gate/useAccessGate";
|
||||
import {
|
||||
getErrorCode,
|
||||
getStandardErrorMessage,
|
||||
@ -13,10 +14,7 @@ import {
|
||||
getBacklinksReferringDomains,
|
||||
getBacklinksTopPages,
|
||||
} from "@/serverFunctions/backlinks";
|
||||
import {
|
||||
getBacklinksAccessSetupStatus,
|
||||
testBacklinksAccess,
|
||||
} from "@/serverFunctions/backlinksAccess";
|
||||
import { getBacklinksAccessSetupStatus } from "@/serverFunctions/backlinksAccess";
|
||||
import { getPersistedBacklinksSearchScope } from "./backlinksSearchScope";
|
||||
|
||||
type UseBacklinksPageDataArgs = {
|
||||
@ -40,18 +38,13 @@ export function useBacklinksPageData({
|
||||
projectId,
|
||||
searchState,
|
||||
}: UseBacklinksPageDataArgs) {
|
||||
const accessStatusQuery = useQuery({
|
||||
const accessGate = useAccessGate({
|
||||
queryKey: ["backlinksAccessStatus", projectId],
|
||||
queryFn: () => getBacklinksAccessSetupStatus({ data: { projectId } }),
|
||||
statusErrorFallback: "Could not load Backlinks setup status.",
|
||||
});
|
||||
const accessStatus = accessStatusQuery.data;
|
||||
const accessStatusErrorMessage = accessStatusQuery.error
|
||||
? getStandardErrorMessage(
|
||||
accessStatusQuery.error,
|
||||
"Could not load Backlinks setup status.",
|
||||
)
|
||||
: null;
|
||||
const backlinksEnabled = accessStatus?.enabled ?? false;
|
||||
const backlinksEnabled = accessGate.enabled;
|
||||
const retryAccessGate = accessGate.onRetry;
|
||||
const requestInput = buildBacklinksRequestInput(projectId, searchState);
|
||||
const searchCardInitialValues = useMemo(
|
||||
() => ({
|
||||
@ -61,13 +54,6 @@ export function useBacklinksPageData({
|
||||
[searchState.scope, searchState.target],
|
||||
);
|
||||
|
||||
const testAccessMutation = useMutation({
|
||||
mutationFn: () => testBacklinksAccess({ data: { projectId } }),
|
||||
onSuccess: async () => {
|
||||
await accessStatusQuery.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const baseQueryKeyParts = [
|
||||
projectId,
|
||||
searchState.scope,
|
||||
@ -118,29 +104,25 @@ export function useBacklinksPageData({
|
||||
useEffect(() => {
|
||||
if (
|
||||
(backlinksDisabledByError || backlinksDisabledByTabError) &&
|
||||
accessStatus?.enabled
|
||||
backlinksEnabled
|
||||
) {
|
||||
void accessStatusQuery.refetch();
|
||||
retryAccessGate();
|
||||
}
|
||||
}, [
|
||||
accessStatus?.enabled,
|
||||
accessStatusQuery,
|
||||
backlinksDisabledByError,
|
||||
backlinksDisabledByTabError,
|
||||
backlinksEnabled,
|
||||
retryAccessGate,
|
||||
]);
|
||||
|
||||
return {
|
||||
accessStatus,
|
||||
accessStatusErrorMessage,
|
||||
accessStatusQuery,
|
||||
accessGate,
|
||||
activeTabErrorMessage,
|
||||
backlinksDisabledByError,
|
||||
backlinksEnabled,
|
||||
overviewErrorMessage,
|
||||
overviewQuery,
|
||||
referringDomainsQuery,
|
||||
searchCardInitialValues,
|
||||
testAccessMutation,
|
||||
topPagesQuery,
|
||||
};
|
||||
}
|
||||
|
||||
50
src/client/features/billing/HostedPlanGate.tsx
Normal file
50
src/client/features/billing/HostedPlanGate.tsx
Normal 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",
|
||||
});
|
||||
}
|
||||
@ -18,6 +18,10 @@ const STANDARD_MESSAGES: Record<ErrorCode, string> = {
|
||||
"Backlinks is not enabled for the connected DataForSEO account yet.",
|
||||
BACKLINKS_BILLING_ISSUE:
|
||||
"The connected DataForSEO account has a billing or balance issue.",
|
||||
AI_SEARCH_NOT_ENABLED:
|
||||
"AI Optimization is not enabled for the connected DataForSEO account yet.",
|
||||
AI_SEARCH_BILLING_ISSUE:
|
||||
"The connected DataForSEO account has a billing or balance issue.",
|
||||
RATE_LIMITED: "Too many requests. Please wait and try again.",
|
||||
CONFLICT: "This request conflicts with existing data.",
|
||||
INTERNAL_ERROR:
|
||||
|
||||
@ -66,18 +66,7 @@ export async function getBrandLookup(
|
||||
),
|
||||
);
|
||||
|
||||
// Credits exhaustion is global, not per-platform — re-throw immediately so
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
rethrowIfBlockingAiSearchError(settled);
|
||||
|
||||
const platformBundles: PlatformOutcome[] = settled.map((settledResult, 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 —
|
||||
// reject so the outer `allSucceeded` gate refuses to cache a blank result.
|
||||
@ -190,14 +179,16 @@ async function fetchPlatformData(
|
||||
};
|
||||
}
|
||||
|
||||
function rethrowIfCreditsExhausted(
|
||||
...results: Array<PromiseSettledResult<unknown>>
|
||||
function rethrowIfBlockingAiSearchError(
|
||||
results: Array<PromiseSettledResult<unknown>>,
|
||||
): void {
|
||||
for (const result of results) {
|
||||
if (
|
||||
result.status === "rejected" &&
|
||||
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;
|
||||
}
|
||||
|
||||
@ -276,10 +276,14 @@ function mapErrorToResult(
|
||||
model: PromptExplorerModel,
|
||||
reason: unknown,
|
||||
): PromptExplorerModelResult {
|
||||
if (reason instanceof AppError && reason.code === "INSUFFICIENT_CREDITS") {
|
||||
// Re-throw INSUFFICIENT_CREDITS so the whole request surfaces it instead
|
||||
// of silently degrading to "Claude failed" — credits exhaustion is global,
|
||||
// not per-model.
|
||||
if (
|
||||
reason instanceof AppError &&
|
||||
(reason.code === "INSUFFICIENT_CREDITS" ||
|
||||
reason.code === "AI_SEARCH_NOT_ENABLED" ||
|
||||
reason.code === "AI_SEARCH_BILLING_ISSUE")
|
||||
) {
|
||||
// These account-level failures apply to every model, so surface one clear
|
||||
// error instead of silently degrading to per-model failures.
|
||||
throw reason;
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
71
src/server/lib/dataforseoAccessClassification.test.ts
Normal file
71
src/server/lib/dataforseoAccessClassification.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
66
src/server/lib/dataforseoAccessClassification.ts
Normal file
66
src/server/lib/dataforseoAccessClassification.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
83
src/server/lib/dataforseoAccountState.ts
Normal file
83
src/server/lib/dataforseoAccountState.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -1,21 +1,28 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import type * as DataforseoBacklinksSupport from "@/server/lib/dataforseoBacklinksSupport";
|
||||
|
||||
vi.mock("@/server/lib/runtime-env", () => ({
|
||||
getRequiredEnvValue: vi.fn(async () => "test-api-key"),
|
||||
}));
|
||||
|
||||
vi.mock("@/server/lib/dataforseoBacklinksAccount", () => ({
|
||||
classifyBacklinksErrorWithAccountState: vi.fn(),
|
||||
const { classifyBacklinksError } = vi.hoisted(() => ({
|
||||
classifyBacklinksError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/server/lib/dataforseoBacklinksSupport", async () => {
|
||||
const actual = await vi.importActual<typeof DataforseoBacklinksSupport>(
|
||||
"@/server/lib/dataforseoBacklinksSupport",
|
||||
);
|
||||
return { ...actual, classifyBacklinksError };
|
||||
});
|
||||
|
||||
import {
|
||||
fetchBacklinksHistoryRaw,
|
||||
fetchBacklinksRowsRaw,
|
||||
fetchBacklinksSummaryRaw,
|
||||
normalizeBacklinksTarget,
|
||||
} from "@/server/lib/dataforseoBacklinks";
|
||||
import { classifyBacklinksErrorWithAccountState } from "@/server/lib/dataforseoBacklinksAccount";
|
||||
|
||||
describe("normalizeBacklinksTarget", () => {
|
||||
it("treats explicit homepage URLs as page lookups", () => {
|
||||
@ -121,18 +128,15 @@ describe("fetchBacklinksSummaryRaw", () => {
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
);
|
||||
vi.mocked(classifyBacklinksErrorWithAccountState).mockImplementation(
|
||||
async (status: number | undefined) => {
|
||||
if (status === 40204) {
|
||||
return new AppError(
|
||||
"BACKLINKS_NOT_ENABLED",
|
||||
"Backlinks is not enabled",
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
);
|
||||
classifyBacklinksError.mockImplementation((status: number | undefined) => {
|
||||
if (status === 40204) {
|
||||
return new AppError(
|
||||
"BACKLINKS_NOT_ENABLED",
|
||||
"Backlinks is not enabled",
|
||||
);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchBacklinksSummaryRaw({
|
||||
@ -140,7 +144,7 @@ describe("fetchBacklinksSummaryRaw", () => {
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "BACKLINKS_NOT_ENABLED" });
|
||||
|
||||
expect(classifyBacklinksErrorWithAccountState).toHaveBeenCalledWith(
|
||||
expect(classifyBacklinksError).toHaveBeenCalledWith(
|
||||
40204,
|
||||
expect.stringContaining("Backlinks subscription required"),
|
||||
"/v3/backlinks/summary/live",
|
||||
@ -164,7 +168,7 @@ describe("fetchBacklinksSummaryRaw", () => {
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
);
|
||||
vi.mocked(classifyBacklinksErrorWithAccountState).mockResolvedValue(null);
|
||||
classifyBacklinksError.mockReturnValue(null);
|
||||
|
||||
await expect(
|
||||
fetchBacklinksSummaryRaw({
|
||||
@ -190,7 +194,7 @@ describe("fetchBacklinksSummaryRaw", () => {
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
);
|
||||
vi.mocked(classifyBacklinksErrorWithAccountState).mockResolvedValue(null);
|
||||
classifyBacklinksError.mockReturnValue(null);
|
||||
|
||||
await expect(
|
||||
fetchBacklinksSummaryRaw({
|
||||
@ -233,7 +237,7 @@ describe("fetchBacklinksSummaryRaw", () => {
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
);
|
||||
vi.mocked(classifyBacklinksErrorWithAccountState).mockResolvedValue(null);
|
||||
classifyBacklinksError.mockReturnValue(null);
|
||||
|
||||
await expect(
|
||||
fetchBacklinksRowsRaw({
|
||||
|
||||
@ -9,6 +9,7 @@ import type {
|
||||
} from "@/server/lib/dataforseoCost";
|
||||
import { getRequiredEnvValue } from "@/server/lib/runtime-env";
|
||||
import {
|
||||
classifyBacklinksError,
|
||||
type BacklinksTaskResult,
|
||||
backlinksHistoryItemSchema,
|
||||
backlinksItemSchema,
|
||||
@ -18,7 +19,6 @@ import {
|
||||
referringDomainItemSchema,
|
||||
responseSchema,
|
||||
} from "@/server/lib/dataforseoBacklinksSupport";
|
||||
import { classifyBacklinksErrorWithAccountState } from "@/server/lib/dataforseoBacklinksAccount";
|
||||
export { normalizeBacklinksTarget } from "@/server/lib/dataforseoBacklinksTarget";
|
||||
|
||||
const API_BASE = "https://api.dataforseo.com";
|
||||
@ -69,7 +69,7 @@ async function postBacklinks(path: string, payload: unknown) {
|
||||
|
||||
const rawText = await response.text();
|
||||
if (!response.ok) {
|
||||
const classifiedError = await classifyBacklinksErrorWithAccountState(
|
||||
const classifiedError = classifyBacklinksError(
|
||||
response.status,
|
||||
rawText,
|
||||
path,
|
||||
@ -85,7 +85,7 @@ async function postBacklinks(path: string, payload: unknown) {
|
||||
try {
|
||||
raw = JSON.parse(rawText);
|
||||
} catch {
|
||||
const classifiedError = await classifyBacklinksErrorWithAccountState(
|
||||
const classifiedError = classifyBacklinksError(
|
||||
response.status,
|
||||
rawText,
|
||||
path,
|
||||
@ -103,7 +103,7 @@ async function postBacklinks(path: string, payload: unknown) {
|
||||
|
||||
const parsed = responseSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
const classifiedError = await classifyBacklinksErrorWithAccountState(
|
||||
const classifiedError = classifyBacklinksError(
|
||||
response.status,
|
||||
rawText,
|
||||
path,
|
||||
@ -121,7 +121,7 @@ async function postBacklinks(path: string, payload: unknown) {
|
||||
|
||||
const responseData = parsed.data;
|
||||
if (responseData.status_code !== 20000) {
|
||||
const classifiedError = await classifyBacklinksErrorWithAccountState(
|
||||
const classifiedError = classifyBacklinksError(
|
||||
responseData.status_code,
|
||||
`${responseData.status_message ?? ""} ${rawText}`,
|
||||
path,
|
||||
@ -139,7 +139,7 @@ async function postBacklinks(path: string, payload: unknown) {
|
||||
}
|
||||
|
||||
if (task.status_code !== 20000) {
|
||||
const classifiedError = await classifyBacklinksErrorWithAccountState(
|
||||
const classifiedError = classifyBacklinksError(
|
||||
task.status_code,
|
||||
`${task.status_message ?? ""} ${rawText}`,
|
||||
path,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { createDataforseoAccessClassifier } from "@/server/lib/dataforseoAccessClassification";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
|
||||
const taskResultSchema = z
|
||||
@ -120,88 +121,15 @@ export const backlinksHistoryItemSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export function classifyBacklinksError(
|
||||
status: number | undefined,
|
||||
details: string,
|
||||
path: string,
|
||||
): 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",
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 40200 || status === 40210 || status === 402) {
|
||||
return new AppError(
|
||||
"BACKLINKS_BILLING_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 const classifyBacklinksError = createDataforseoAccessClassifier({
|
||||
pathPrefix: "/backlinks/",
|
||||
notEnabledCode: "BACKLINKS_NOT_ENABLED",
|
||||
notEnabledMessage:
|
||||
"Backlinks is not enabled for the connected DataForSEO account",
|
||||
billingIssueCode: "BACKLINKS_BILLING_ISSUE",
|
||||
billingIssueMessage:
|
||||
"The connected DataForSEO account has a billing or balance issue",
|
||||
});
|
||||
|
||||
export function parseItems<T extends z.ZodTypeAny>(
|
||||
endpointName: string,
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
type LlmTopPagesItem,
|
||||
} from "@/server/lib/dataforseoLlmSchemas";
|
||||
import type { DataforseoApiResponse } from "@/server/lib/dataforseoCost";
|
||||
import { createDataforseoAccessClassifier } from "@/server/lib/dataforseoAccessClassification";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
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();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new AppError(
|
||||
"INTERNAL_ERROR",
|
||||
`DataForSEO HTTP ${response.status} on ${path}. Response: ${truncate(rawText)}`,
|
||||
throw (
|
||||
classifyAiSearchError(response.status, rawText, path) ??
|
||||
new AppError(
|
||||
"INTERNAL_ERROR",
|
||||
`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 {
|
||||
return text.length > MAX_DATAFORSEO_ERROR_PAYLOAD_LENGTH
|
||||
? `${text.slice(0, MAX_DATAFORSEO_ERROR_PAYLOAD_LENGTH)}... [truncated]`
|
||||
@ -86,9 +100,10 @@ function parseEnvelope(path: string, raw: unknown): LlmDataforseoTask {
|
||||
|
||||
const data = envelope.data;
|
||||
if (data.status_code !== 20000) {
|
||||
throw new AppError(
|
||||
"INTERNAL_ERROR",
|
||||
data.status_message || `DataForSEO ${path} request failed`,
|
||||
const message = data.status_message || `DataForSEO ${path} request failed`;
|
||||
throw (
|
||||
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) {
|
||||
throw new AppError(
|
||||
"INTERNAL_ERROR",
|
||||
task.status_message || `DataForSEO ${path} task failed`,
|
||||
const message = task.status_message || `DataForSEO ${path} task failed`;
|
||||
throw (
|
||||
classifyAiSearchError(task.status_code, message, path) ??
|
||||
new AppError("INTERNAL_ERROR", message)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -26,15 +26,6 @@ export async function isHostedServerAuthMode(): Promise<boolean> {
|
||||
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> {
|
||||
if (!workersEnvPromise) {
|
||||
workersEnvPromise = loadWorkersEnv();
|
||||
|
||||
34
src/serverFunctions/aiSearchAccess.ts
Normal file
34
src/serverFunctions/aiSearchAccess.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
@ -1,10 +1,5 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import {
|
||||
buildBacklinksDisabledAccessStatus,
|
||||
setBacklinksAccessStatus,
|
||||
} from "@/server/features/backlinks/backlinksAccess";
|
||||
import { BacklinksService } from "@/server/features/backlinks/services/BacklinksService";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import { requireProjectContext } from "@/serverFunctions/middleware";
|
||||
import { backlinksOverviewInputSchema } from "@/types/schemas/backlinks";
|
||||
|
||||
@ -14,31 +9,20 @@ export const getBacklinksOverview = createServerFn({
|
||||
.middleware(requireProjectContext)
|
||||
.inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
try {
|
||||
const input = {
|
||||
target: data.target,
|
||||
scope: data.scope,
|
||||
};
|
||||
const spamOptions = {
|
||||
hideSpam: data.hideSpam,
|
||||
spamThreshold: data.spamThreshold,
|
||||
};
|
||||
const profile = await BacklinksService.profileOverview(
|
||||
input,
|
||||
context,
|
||||
spamOptions,
|
||||
);
|
||||
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;
|
||||
}
|
||||
const input = {
|
||||
target: data.target,
|
||||
scope: data.scope,
|
||||
};
|
||||
const spamOptions = {
|
||||
hideSpam: data.hideSpam,
|
||||
spamThreshold: data.spamThreshold,
|
||||
};
|
||||
const profile = await BacklinksService.profileOverview(
|
||||
input,
|
||||
context,
|
||||
spamOptions,
|
||||
);
|
||||
return profile.overview;
|
||||
});
|
||||
|
||||
export const getBacklinksReferringDomains = createServerFn({
|
||||
@ -47,20 +31,15 @@ export const getBacklinksReferringDomains = createServerFn({
|
||||
.middleware(requireProjectContext)
|
||||
.inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
try {
|
||||
const input = {
|
||||
target: data.target,
|
||||
scope: data.scope,
|
||||
};
|
||||
const profile = await BacklinksService.profileReferringDomains(
|
||||
input,
|
||||
context,
|
||||
);
|
||||
return profile.rows;
|
||||
} catch (error) {
|
||||
await updateBacklinksAccessStatusOnError(error);
|
||||
throw error;
|
||||
}
|
||||
const input = {
|
||||
target: data.target,
|
||||
scope: data.scope,
|
||||
};
|
||||
const profile = await BacklinksService.profileReferringDomains(
|
||||
input,
|
||||
context,
|
||||
);
|
||||
return profile.rows;
|
||||
});
|
||||
|
||||
export const getBacklinksTopPages = createServerFn({
|
||||
@ -69,24 +48,10 @@ export const getBacklinksTopPages = createServerFn({
|
||||
.middleware(requireProjectContext)
|
||||
.inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
try {
|
||||
const input = {
|
||||
target: data.target,
|
||||
scope: data.scope,
|
||||
};
|
||||
const profile = await BacklinksService.profileTopPages(input, context);
|
||||
return profile.rows;
|
||||
} catch (error) {
|
||||
await updateBacklinksAccessStatusOnError(error);
|
||||
throw error;
|
||||
}
|
||||
const input = {
|
||||
target: data.target,
|
||||
scope: data.scope,
|
||||
};
|
||||
const profile = await BacklinksService.profileTopPages(input, context);
|
||||
return profile.rows;
|
||||
});
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,78 +1,34 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import {
|
||||
buildBacklinksDisabledAccessStatus,
|
||||
buildVerifiedBacklinksAccessStatus,
|
||||
getBacklinksAccessStatus,
|
||||
setBacklinksAccessStatus,
|
||||
} from "@/server/features/backlinks/backlinksAccess";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import { createDataforseoClient } from "@/server/lib/dataforseoClient";
|
||||
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_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({
|
||||
method: "GET",
|
||||
})
|
||||
type BacklinksAccessStatus = {
|
||||
enabled: boolean;
|
||||
errorMessage: string | null;
|
||||
};
|
||||
|
||||
export const getBacklinksAccessSetupStatus = createServerFn({ method: "GET" })
|
||||
.middleware(requireProjectContext)
|
||||
.inputValidator((data: unknown) => backlinksProjectSchema.parse(data))
|
||||
.handler(async () => getBacklinksAccessStatus());
|
||||
|
||||
export const testBacklinksAccess = createServerFn({
|
||||
method: "POST",
|
||||
})
|
||||
.middleware(requireProjectContext)
|
||||
.inputValidator((data: unknown) => backlinksProjectSchema.parse(data))
|
||||
.handler(async ({ context }) => {
|
||||
.handler(async (): Promise<BacklinksAccessStatus> => {
|
||||
if (await isHostedServerAuthMode()) {
|
||||
// Hosted deployments do not run the manual DataForSEO access test here;
|
||||
// backlinks access is treated as platform-managed in this mode.
|
||||
return getBacklinksAccessStatus();
|
||||
return { enabled: true, errorMessage: null };
|
||||
}
|
||||
|
||||
const cachedStatus = await getBacklinksAccessStatus();
|
||||
if (isRecentVerifiedBacklinksAccessCheck(cachedStatus)) {
|
||||
return cachedStatus;
|
||||
}
|
||||
|
||||
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 status;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
const state = await fetchDataforseoAccountState();
|
||||
const enabled = hasActiveDataforseoSubscription(
|
||||
state?.backlinksSubscriptionExpiryDate ?? null,
|
||||
);
|
||||
return {
|
||||
enabled,
|
||||
errorMessage: enabled ? null : BACKLINKS_NOT_ENABLED_MESSAGE,
|
||||
};
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@ -12,6 +12,8 @@ const ERROR_CODES = [
|
||||
"CRAWL_TARGET_BLOCKED",
|
||||
"BACKLINKS_NOT_ENABLED",
|
||||
"BACKLINKS_BILLING_ISSUE",
|
||||
"AI_SEARCH_NOT_ENABLED",
|
||||
"AI_SEARCH_BILLING_ISSUE",
|
||||
"RATE_LIMITED",
|
||||
"CONFLICT",
|
||||
"INTERNAL_ERROR",
|
||||
|
||||
@ -7,6 +7,14 @@ import { z } from "zod";
|
||||
* by server functions, services, R2 cache validation, and the client UI.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI Search access setup (self-hosted mode only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const aiSearchProjectSchema = z.object({
|
||||
projectId: z.string().min(1),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Brand Lookup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user