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