diff --git a/src/client/features/access-gate/AccessGate.tsx b/src/client/features/access-gate/AccessGate.tsx new file mode 100644 index 0000000..4d3c479 --- /dev/null +++ b/src/client/features/access-gate/AccessGate.tsx @@ -0,0 +1,83 @@ +import type { ReactNode } from "react"; +import { ShieldAlert, Wrench } from "lucide-react"; + +export function AccessGateLoadingState() { + return ( +
+
+
+
+
+
+
+
+ ); +} + +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 ( +
+
+
+
+ +
+
+

{title}

+
{bodyText}
+ {helperText ? ( +
{helperText}
+ ) : null} +
+
+ +
+ + + {externalLabel} + +
+ + {errorMessage ? ( +
+ + {errorMessage} +
+ ) : null} +
+
+ ); +} diff --git a/src/client/features/access-gate/useAccessGate.ts b/src/client/features/access-gate/useAccessGate.ts new file mode 100644 index 0000000..038659b --- /dev/null +++ b/src/client/features/access-gate/useAccessGate.ts @@ -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; + 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, + }; +} diff --git a/src/client/features/ai-search/BrandLookupPage.tsx b/src/client/features/ai-search/BrandLookupPage.tsx index 2c69fe7..61f9b2d 100644 --- a/src/client/features/ai-search/BrandLookupPage.tsx +++ b/src/client/features/ai-search/BrandLookupPage.tsx @@ -1,6 +1,5 @@ import { useEffect, useState, type FormEvent } from "react"; import { useQuery } from "@tanstack/react-query"; -import { AutumnProvider, useCustomer } from "autumn-js/react"; import { AlertCircle, ArrowLeft, @@ -9,14 +8,21 @@ import { TrendingUp, } from "lucide-react"; import { lookupBrand } from "@/serverFunctions/ai-search"; -import { useSession } from "@/lib/auth-client"; -import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection"; +import { + HostedPlanGate, + type HostedPlanGateState, +} from "@/client/features/billing/HostedPlanGate"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { BrandLookupResults } from "@/client/features/ai-search/components/BrandLookupResults"; import { BrandLookupSearchCard } from "@/client/features/ai-search/components/BrandLookupSearchCard"; import { BrandLookupHistorySection } from "@/client/features/ai-search/components/BrandLookupHistorySection"; import { AiSearchLoadingState } from "@/client/features/ai-search/components/AiSearchLoadingState"; import { AiSearchPaidPlanGate } from "@/client/features/ai-search/components/AiSearchPaidPlanGate"; +import { + AiSearchAccessLoadingState, + AiSearchSetupGate, +} from "@/client/features/ai-search/components/AiSearchSetupGate"; +import { useAiSearchAccess } from "@/client/features/ai-search/useAiSearchAccess"; import { useBrandLookupSearchHistory } from "@/client/hooks/useBrandLookupSearchHistory"; import { BRAND_LOOKUP_MAX_INPUT_LENGTH } from "@/types/schemas/ai-search"; @@ -46,9 +52,9 @@ const BRAND_LOOKUP_BULLETS = [ export function BrandLookupPage(props: Props) { return ( - - - + + {(planGate) => } + ); } @@ -56,18 +62,12 @@ function BrandLookupPageInner({ projectId, initialQuery, onQueryChange, -}: Props) { + planGate, +}: Props & { planGate: HostedPlanGateState }) { const [query, setQuery] = useState(initialQuery); const [validationError, setValidationError] = useState(null); - const { data: session } = useSession(); - const customerQuery = useCustomer({ - queryOptions: { enabled: Boolean(session?.user?.id) }, - }); - const planKnown = customerQuery.isSuccess || customerQuery.isError; - const isFreePlan = - !!customerQuery.data && - getCustomerPlanStatus(customerQuery.data) === "free"; + const access = useAiSearchAccess(projectId); const trimmedInitialQuery = initialQuery.trim(); const hasActiveQuery = trimmedInitialQuery.length > 0; @@ -83,7 +83,7 @@ function BrandLookupPageInner({ languageCode: "en", }, }), - enabled: hasActiveQuery && !isFreePlan, + enabled: hasActiveQuery && !planGate.isFreePlan && access.enabled, staleTime: 5 * 60 * 1000, retry: false, }); @@ -137,7 +137,7 @@ function BrandLookupPageInner({ : null; const resultData = hasActiveQuery ? lookupQuery.data : undefined; - if (!planKnown) return null; + if (planGate.isLoading) return null; return (
@@ -149,7 +149,15 @@ function BrandLookupPageInner({

- {isFreePlan ? ( + {access.isLoading ? ( + + ) : !access.enabled ? ( + + ) : planGate.isFreePlan ? ( - - + + {(planGate) => } + ); } -function PromptExplorerPageInner({ projectId }: Props) { +function PromptExplorerPageInner({ + projectId, + planGate, +}: Props & { planGate: HostedPlanGateState }) { const [form, setForm] = useState(INITIAL_FORM_STATE); const [validationError, setValidationError] = useState(null); - - const { data: session } = useSession(); - const customerQuery = useCustomer({ - queryOptions: { enabled: Boolean(session?.user?.id) }, - }); - const planKnown = customerQuery.isSuccess || customerQuery.isError; - const isFreePlan = - !!customerQuery.data && - getCustomerPlanStatus(customerQuery.data) === "free"; + const access = useAiSearchAccess(projectId); const { history, @@ -177,7 +178,7 @@ function PromptExplorerPageInner({ projectId }: Props) { if (validationError) setValidationError(null); }; - if (!planKnown) return null; + if (planGate.isLoading) return null; return (
@@ -190,7 +191,15 @@ function PromptExplorerPageInner({ projectId }: Props) {

- {isFreePlan ? ( + {access.isLoading ? ( + + ) : !access.enabled ? ( + + ) : planGate.isFreePlan ? ( ; +} + +export function AiSearchSetupGate({ + errorMessage, + isRefetching, + onRetry, +}: { + errorMessage: string | null; + isRefetching: boolean; + onRetry: () => void; +}) { + return ( + + We are also planning an API so self-hosted apps can use OpenSEO's LLM + Mentions data directly. Until then, . + + } + 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 ( + + use managed OpenSEO + + ); +} diff --git a/src/client/features/ai-search/useAiSearchAccess.ts b/src/client/features/ai-search/useAiSearchAccess.ts new file mode 100644 index 0000000..a720346 --- /dev/null +++ b/src/client/features/ai-search/useAiSearchAccess.ts @@ -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.", + }); +} diff --git a/src/client/features/backlinks/BacklinksPage.tsx b/src/client/features/backlinks/BacklinksPage.tsx index daf2678..d69e7f8 100644 --- a/src/client/features/backlinks/BacklinksPage.tsx +++ b/src/client/features/backlinks/BacklinksPage.tsx @@ -9,7 +9,6 @@ import { } from "./useBacklinksPageData"; import { useBacklinksFilters } from "./useBacklinksFilters"; import { useBacklinksSearchHistory } from "@/client/hooks/useBacklinksSearchHistory"; -import { getStandardErrorMessage } from "@/client/lib/error-messages"; export function BacklinksPage({ projectId, @@ -18,17 +17,13 @@ export function BacklinksPage({ }: BacklinksPageProps) { const filters = useBacklinksFilters(); const { - accessStatus, - accessStatusErrorMessage, - accessStatusQuery, + accessGate, activeTabErrorMessage, backlinksDisabledByError, - backlinksEnabled, overviewErrorMessage, overviewQuery, referringDomainsQuery, searchCardInitialValues, - testAccessMutation, topPagesQuery, } = useBacklinksPageData({ projectId, @@ -63,8 +58,8 @@ export function BacklinksPage({

- {!accessStatusQuery.isLoading && - backlinksEnabled && + {!accessGate.isLoading && + accessGate.enabled && !backlinksDisabledByError ? ( void accessStatusQuery.refetch()} onSelectHistoryItem={handleHistorySelect} onShowHistory={() => navigateToBacklinksHistory(navigate)} onSetActiveTab={(tab) => navigateToBacklinksTab(navigate, tab)} onRetryOverview={() => void overviewQuery.refetch()} - onTestAccess={() => testAccessMutation.mutate()} />
diff --git a/src/client/features/backlinks/BacklinksPageContent.tsx b/src/client/features/backlinks/BacklinksPageContent.tsx index f91cd03..e5f0589 100644 --- a/src/client/features/backlinks/BacklinksPageContent.tsx +++ b/src/client/features/backlinks/BacklinksPageContent.tsx @@ -12,12 +12,12 @@ import { import { BacklinksHistorySection } from "./BacklinksHistorySection"; import type { BacklinksSearchHistoryItem } from "@/client/hooks/useBacklinksSearchHistory"; import type { - BacklinksAccessStatusData, BacklinksOverviewData, BacklinksReferringDomainsData, BacklinksSearchState, BacklinksTopPagesData, } from "./backlinksPageTypes"; +import type { UseAccessGateResult } from "@/client/features/access-gate/useAccessGate"; import { buildSummaryStats } from "./backlinksPageUtils"; import { filterBacklinkRows, @@ -27,13 +27,10 @@ import { import type { BacklinksFiltersState } from "./useBacklinksFilters"; type BacklinksBodyProps = { - accessStatus: BacklinksAccessStatusData | undefined; - accessStatusError: string | null; + accessGate: UseAccessGateResult; backlinksDisabledByError: boolean; - backlinksEnabled: boolean; history: BacklinksSearchHistoryItem[]; historyLoaded: boolean; - isAccessStatusLoading: boolean; overviewData: BacklinksOverviewData | undefined; overviewError: string | null; overviewLoading: boolean; @@ -42,26 +39,19 @@ type BacklinksBodyProps = { filters: BacklinksFiltersState; tabErrorMessage: string | null; tabLoading: boolean; - testError: string | null; - testIsPending: boolean; topPages: BacklinksTopPagesData | undefined; onRemoveHistoryItem: (timestamp: number) => void; - onRetryAccess: () => void; onSelectHistoryItem: (item: BacklinksSearchHistoryItem) => void; onShowHistory: () => void; onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void; onRetryOverview: () => void; - onTestAccess: () => void; }; export function BacklinksBody({ - accessStatus, - accessStatusError, + accessGate, backlinksDisabledByError, - backlinksEnabled, history, historyLoaded, - isAccessStatusLoading, overviewData, overviewError, overviewLoading, @@ -70,16 +60,12 @@ export function BacklinksBody({ filters, tabErrorMessage, tabLoading, - testError, - testIsPending, topPages, onRemoveHistoryItem, - onRetryAccess, onSelectHistoryItem, onShowHistory, onSetActiveTab, onRetryOverview, - onTestAccess, }: BacklinksBodyProps) { const mergedData = useMemo( () => mergeTabData(overviewData, referringDomains, topPages), @@ -111,26 +97,25 @@ export function BacklinksBody({ [mergedData], ); - if (isAccessStatusLoading) { + if (accessGate.isLoading) { return ; } - if (accessStatusError) { + if (accessGate.statusErrorMessage) { return ( ); } - if (!backlinksEnabled || backlinksDisabledByError) { + if (!accessGate.enabled || backlinksDisabledByError) { return ( ); } diff --git a/src/client/features/backlinks/BacklinksPageStates.tsx b/src/client/features/backlinks/BacklinksPageStates.tsx index 0a44ab0..0ef3b3c 100644 --- a/src/client/features/backlinks/BacklinksPageStates.tsx +++ b/src/client/features/backlinks/BacklinksPageStates.tsx @@ -1,73 +1,40 @@ -import { ShieldAlert, Wrench } from "lucide-react"; -import type { BacklinksAccessStatusData } from "./backlinksPageTypes"; -import { formatRelativeTimestamp } from "./backlinksPageUtils"; +import { ShieldAlert } from "lucide-react"; +import { + AccessGate, + AccessGateLoadingState, +} from "@/client/features/access-gate/AccessGate"; export function BacklinksAccessLoadingState() { - return ( -
-
-
-
-
-
-
-
- ); + return ; } export function BacklinksSetupGate({ - status, - isTesting, - testError, - onTest, + errorMessage, + isRefetching, + onRetry, }: { - status: BacklinksAccessStatusData | undefined; - isTesting: boolean; - testError: string | null; - onTest: () => void; + errorMessage: string | null; + isRefetching: boolean; + onRetry: () => void; }) { return ( -
-
-
-
- -
-
-

Enable Backlinks

-

- Backlinks is not enabled for your DataForSEO account yet. Turn it - on in DataForSEO, then test access here. -

-

- DataForSEO offers a free 14-day trial for Backlinks. Then, it's - $100/month. We're gauging interest in building out a lower-cost - alternative, if you're interested. -

-
-
- -
- - - Open DataForSEO Backlinks - -
- - -
-
+ + We are also planning a Backlinks API so self-hosted apps can use + OpenSEO's backlinks data directly. Until then,{" "} + . + + } + buttonLabel="Confirm DataForSEO Access" + externalUrl="https://app.dataforseo.com/api-access-subscriptions" + externalLabel="Open DataForSEO Backlinks" + errorMessage={errorMessage} + isRefetching={isRefetching} + onRetry={onRetry} + /> ); } @@ -131,45 +98,15 @@ export function BacklinksErrorState({ ); } -function BacklinksSetupFeedback({ - status, - testError, -}: { - status: BacklinksAccessStatusData | undefined; - testError: string | null; -}) { - return ( -
- {status?.lastCheckedAt ? ( -
- Last checked {formatRelativeTimestamp(status.lastCheckedAt)}. -
- ) : null} - {status?.lastErrorMessage ? ( -
- - {status.lastErrorMessage} -
- ) : null} - {testError ? ( -
- - {testError} -
- ) : null} -
- ); -} - -function InlineMailingListLink() { +function InlineManagedOpenSeoLink() { return ( - join the OpenSEO mailing list + use managed OpenSEO ); } diff --git a/src/client/features/backlinks/backlinksPageTypes.ts b/src/client/features/backlinks/backlinksPageTypes.ts index edfc78c..3c53a9b 100644 --- a/src/client/features/backlinks/backlinksPageTypes.ts +++ b/src/client/features/backlinks/backlinksPageTypes.ts @@ -7,14 +7,10 @@ import type { getBacklinksReferringDomains, getBacklinksTopPages, } from "@/serverFunctions/backlinks"; -import type { getBacklinksAccessSetupStatus } from "@/serverFunctions/backlinksAccess"; export type BacklinksOverviewData = Awaited< ReturnType >; -export type BacklinksAccessStatusData = Awaited< - ReturnType ->; export type BacklinksReferringDomainsData = Awaited< ReturnType >; diff --git a/src/client/features/backlinks/useBacklinksPageData.ts b/src/client/features/backlinks/useBacklinksPageData.ts index 229ce5d..fd98540 100644 --- a/src/client/features/backlinks/useBacklinksPageData.ts +++ b/src/client/features/backlinks/useBacklinksPageData.ts @@ -1,9 +1,10 @@ import { useEffect, useMemo } from "react"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import type { BacklinksPageProps, BacklinksSearchState, } from "./backlinksPageTypes"; +import { useAccessGate } from "@/client/features/access-gate/useAccessGate"; import { getErrorCode, getStandardErrorMessage, @@ -13,10 +14,7 @@ import { getBacklinksReferringDomains, getBacklinksTopPages, } from "@/serverFunctions/backlinks"; -import { - getBacklinksAccessSetupStatus, - testBacklinksAccess, -} from "@/serverFunctions/backlinksAccess"; +import { getBacklinksAccessSetupStatus } from "@/serverFunctions/backlinksAccess"; import { getPersistedBacklinksSearchScope } from "./backlinksSearchScope"; type UseBacklinksPageDataArgs = { @@ -40,18 +38,13 @@ export function useBacklinksPageData({ projectId, searchState, }: UseBacklinksPageDataArgs) { - const accessStatusQuery = useQuery({ + const accessGate = useAccessGate({ queryKey: ["backlinksAccessStatus", projectId], queryFn: () => getBacklinksAccessSetupStatus({ data: { projectId } }), + statusErrorFallback: "Could not load Backlinks setup status.", }); - const accessStatus = accessStatusQuery.data; - const accessStatusErrorMessage = accessStatusQuery.error - ? getStandardErrorMessage( - accessStatusQuery.error, - "Could not load Backlinks setup status.", - ) - : null; - const backlinksEnabled = accessStatus?.enabled ?? false; + const backlinksEnabled = accessGate.enabled; + const retryAccessGate = accessGate.onRetry; const requestInput = buildBacklinksRequestInput(projectId, searchState); const searchCardInitialValues = useMemo( () => ({ @@ -61,13 +54,6 @@ export function useBacklinksPageData({ [searchState.scope, searchState.target], ); - const testAccessMutation = useMutation({ - mutationFn: () => testBacklinksAccess({ data: { projectId } }), - onSuccess: async () => { - await accessStatusQuery.refetch(); - }, - }); - const baseQueryKeyParts = [ projectId, searchState.scope, @@ -118,29 +104,25 @@ export function useBacklinksPageData({ useEffect(() => { if ( (backlinksDisabledByError || backlinksDisabledByTabError) && - accessStatus?.enabled + backlinksEnabled ) { - void accessStatusQuery.refetch(); + retryAccessGate(); } }, [ - accessStatus?.enabled, - accessStatusQuery, backlinksDisabledByError, backlinksDisabledByTabError, + backlinksEnabled, + retryAccessGate, ]); return { - accessStatus, - accessStatusErrorMessage, - accessStatusQuery, + accessGate, activeTabErrorMessage, backlinksDisabledByError, - backlinksEnabled, overviewErrorMessage, overviewQuery, referringDomainsQuery, searchCardInitialValues, - testAccessMutation, topPagesQuery, }; } diff --git a/src/client/features/billing/HostedPlanGate.tsx b/src/client/features/billing/HostedPlanGate.tsx new file mode 100644 index 0000000..0508a47 --- /dev/null +++ b/src/client/features/billing/HostedPlanGate.tsx @@ -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 ( + + {children} + + ); +} + +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", + }); +} diff --git a/src/client/lib/error-messages.ts b/src/client/lib/error-messages.ts index 9aa0caa..48439c8 100644 --- a/src/client/lib/error-messages.ts +++ b/src/client/lib/error-messages.ts @@ -18,6 +18,10 @@ const STANDARD_MESSAGES: Record = { "Backlinks is not enabled for the connected DataForSEO account yet.", BACKLINKS_BILLING_ISSUE: "The connected DataForSEO account has a billing or balance issue.", + AI_SEARCH_NOT_ENABLED: + "AI Optimization is not enabled for the connected DataForSEO account yet.", + AI_SEARCH_BILLING_ISSUE: + "The connected DataForSEO account has a billing or balance issue.", RATE_LIMITED: "Too many requests. Please wait and try again.", CONFLICT: "This request conflicts with existing data.", INTERNAL_ERROR: diff --git a/src/server/features/ai-search/services/brandLookup.ts b/src/server/features/ai-search/services/brandLookup.ts index 2e9b6b6..06b65f7 100644 --- a/src/server/features/ai-search/services/brandLookup.ts +++ b/src/server/features/ai-search/services/brandLookup.ts @@ -66,18 +66,7 @@ export async function getBrandLookup( ), ); - // Credits exhaustion is global, not per-platform — re-throw immediately so - // the user gets a single clear "out of credits" error instead of a - // half-rendered result. - for (const settledResult of settled) { - if ( - settledResult.status === "rejected" && - settledResult.reason instanceof AppError && - settledResult.reason.code === "INSUFFICIENT_CREDITS" - ) { - throw settledResult.reason; - } - } + rethrowIfBlockingAiSearchError(settled); const platformBundles: PlatformOutcome[] = settled.map((settledResult, i) => { const platform = PLATFORMS[i]; @@ -173,7 +162,7 @@ async function fetchPlatformData( }), ]); - rethrowIfCreditsExhausted(aggregated, topPages, mentions); + rethrowIfBlockingAiSearchError([aggregated, topPages, mentions]); // If every sub-call failed we have nothing to render for this platform — // reject so the outer `allSucceeded` gate refuses to cache a blank result. @@ -190,14 +179,16 @@ async function fetchPlatformData( }; } -function rethrowIfCreditsExhausted( - ...results: Array> +function rethrowIfBlockingAiSearchError( + results: Array>, ): void { for (const result of results) { if ( result.status === "rejected" && result.reason instanceof AppError && - result.reason.code === "INSUFFICIENT_CREDITS" + (result.reason.code === "INSUFFICIENT_CREDITS" || + result.reason.code === "AI_SEARCH_NOT_ENABLED" || + result.reason.code === "AI_SEARCH_BILLING_ISSUE") ) { throw result.reason; } diff --git a/src/server/features/ai-search/services/promptExplorer.ts b/src/server/features/ai-search/services/promptExplorer.ts index 06ae3a9..1328ce8 100644 --- a/src/server/features/ai-search/services/promptExplorer.ts +++ b/src/server/features/ai-search/services/promptExplorer.ts @@ -276,10 +276,14 @@ function mapErrorToResult( model: PromptExplorerModel, reason: unknown, ): PromptExplorerModelResult { - if (reason instanceof AppError && reason.code === "INSUFFICIENT_CREDITS") { - // Re-throw INSUFFICIENT_CREDITS so the whole request surfaces it instead - // of silently degrading to "Claude failed" — credits exhaustion is global, - // not per-model. + if ( + reason instanceof AppError && + (reason.code === "INSUFFICIENT_CREDITS" || + reason.code === "AI_SEARCH_NOT_ENABLED" || + reason.code === "AI_SEARCH_BILLING_ISSUE") + ) { + // These account-level failures apply to every model, so surface one clear + // error instead of silently degrading to per-model failures. throw reason; } diff --git a/src/server/features/backlinks/backlinksAccess.test.ts b/src/server/features/backlinks/backlinksAccess.test.ts deleted file mode 100644 index c9d0c3a..0000000 --- a/src/server/features/backlinks/backlinksAccess.test.ts +++ /dev/null @@ -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(), -})); - -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, - }); - }); -}); diff --git a/src/server/features/backlinks/backlinksAccess.ts b/src/server/features/backlinks/backlinksAccess.ts deleted file mode 100644 index 6402eb0..0000000 --- a/src/server/features/backlinks/backlinksAccess.ts +++ /dev/null @@ -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; - -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 { - 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 { - 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 { - 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; - } -} diff --git a/src/server/lib/dataforseoAccessClassification.test.ts b/src/server/lib/dataforseoAccessClassification.test.ts new file mode 100644 index 0000000..9f0f972 --- /dev/null +++ b/src/server/lib/dataforseoAccessClassification.test.ts @@ -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"); + }); +}); diff --git a/src/server/lib/dataforseoAccessClassification.ts b/src/server/lib/dataforseoAccessClassification.ts new file mode 100644 index 0000000..477c55d --- /dev/null +++ b/src/server/lib/dataforseoAccessClassification.ts @@ -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); + }; +} diff --git a/src/server/lib/dataforseoAccountState.ts b/src/server/lib/dataforseoAccountState.ts new file mode 100644 index 0000000..71461d3 --- /dev/null +++ b/src/server/lib/dataforseoAccountState.ts @@ -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 { + 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, + }; +} diff --git a/src/server/lib/dataforseoBacklinks.test.ts b/src/server/lib/dataforseoBacklinks.test.ts index fd31f60..2f4e1e0 100644 --- a/src/server/lib/dataforseoBacklinks.test.ts +++ b/src/server/lib/dataforseoBacklinks.test.ts @@ -1,21 +1,28 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AppError } from "@/server/lib/errors"; +import type * as DataforseoBacklinksSupport from "@/server/lib/dataforseoBacklinksSupport"; vi.mock("@/server/lib/runtime-env", () => ({ getRequiredEnvValue: vi.fn(async () => "test-api-key"), })); -vi.mock("@/server/lib/dataforseoBacklinksAccount", () => ({ - classifyBacklinksErrorWithAccountState: vi.fn(), +const { classifyBacklinksError } = vi.hoisted(() => ({ + classifyBacklinksError: vi.fn(), })); +vi.mock("@/server/lib/dataforseoBacklinksSupport", async () => { + const actual = await vi.importActual( + "@/server/lib/dataforseoBacklinksSupport", + ); + return { ...actual, classifyBacklinksError }; +}); + import { fetchBacklinksHistoryRaw, fetchBacklinksRowsRaw, fetchBacklinksSummaryRaw, normalizeBacklinksTarget, } from "@/server/lib/dataforseoBacklinks"; -import { classifyBacklinksErrorWithAccountState } from "@/server/lib/dataforseoBacklinksAccount"; describe("normalizeBacklinksTarget", () => { it("treats explicit homepage URLs as page lookups", () => { @@ -121,18 +128,15 @@ describe("fetchBacklinksSummaryRaw", () => { { status: 200, headers: { "Content-Type": "application/json" } }, ), ); - vi.mocked(classifyBacklinksErrorWithAccountState).mockImplementation( - async (status: number | undefined) => { - if (status === 40204) { - return new AppError( - "BACKLINKS_NOT_ENABLED", - "Backlinks is not enabled", - ); - } - - return null; - }, - ); + classifyBacklinksError.mockImplementation((status: number | undefined) => { + if (status === 40204) { + return new AppError( + "BACKLINKS_NOT_ENABLED", + "Backlinks is not enabled", + ); + } + return null; + }); await expect( fetchBacklinksSummaryRaw({ @@ -140,7 +144,7 @@ describe("fetchBacklinksSummaryRaw", () => { }), ).rejects.toMatchObject({ code: "BACKLINKS_NOT_ENABLED" }); - expect(classifyBacklinksErrorWithAccountState).toHaveBeenCalledWith( + expect(classifyBacklinksError).toHaveBeenCalledWith( 40204, expect.stringContaining("Backlinks subscription required"), "/v3/backlinks/summary/live", @@ -164,7 +168,7 @@ describe("fetchBacklinksSummaryRaw", () => { { status: 200, headers: { "Content-Type": "application/json" } }, ), ); - vi.mocked(classifyBacklinksErrorWithAccountState).mockResolvedValue(null); + classifyBacklinksError.mockReturnValue(null); await expect( fetchBacklinksSummaryRaw({ @@ -190,7 +194,7 @@ describe("fetchBacklinksSummaryRaw", () => { { status: 200, headers: { "Content-Type": "application/json" } }, ), ); - vi.mocked(classifyBacklinksErrorWithAccountState).mockResolvedValue(null); + classifyBacklinksError.mockReturnValue(null); await expect( fetchBacklinksSummaryRaw({ @@ -233,7 +237,7 @@ describe("fetchBacklinksSummaryRaw", () => { { status: 200, headers: { "Content-Type": "application/json" } }, ), ); - vi.mocked(classifyBacklinksErrorWithAccountState).mockResolvedValue(null); + classifyBacklinksError.mockReturnValue(null); await expect( fetchBacklinksRowsRaw({ diff --git a/src/server/lib/dataforseoBacklinks.ts b/src/server/lib/dataforseoBacklinks.ts index 3710704..7b5775b 100644 --- a/src/server/lib/dataforseoBacklinks.ts +++ b/src/server/lib/dataforseoBacklinks.ts @@ -9,6 +9,7 @@ import type { } from "@/server/lib/dataforseoCost"; import { getRequiredEnvValue } from "@/server/lib/runtime-env"; import { + classifyBacklinksError, type BacklinksTaskResult, backlinksHistoryItemSchema, backlinksItemSchema, @@ -18,7 +19,6 @@ import { referringDomainItemSchema, responseSchema, } from "@/server/lib/dataforseoBacklinksSupport"; -import { classifyBacklinksErrorWithAccountState } from "@/server/lib/dataforseoBacklinksAccount"; export { normalizeBacklinksTarget } from "@/server/lib/dataforseoBacklinksTarget"; const API_BASE = "https://api.dataforseo.com"; @@ -69,7 +69,7 @@ async function postBacklinks(path: string, payload: unknown) { const rawText = await response.text(); if (!response.ok) { - const classifiedError = await classifyBacklinksErrorWithAccountState( + const classifiedError = classifyBacklinksError( response.status, rawText, path, @@ -85,7 +85,7 @@ async function postBacklinks(path: string, payload: unknown) { try { raw = JSON.parse(rawText); } catch { - const classifiedError = await classifyBacklinksErrorWithAccountState( + const classifiedError = classifyBacklinksError( response.status, rawText, path, @@ -103,7 +103,7 @@ async function postBacklinks(path: string, payload: unknown) { const parsed = responseSchema.safeParse(raw); if (!parsed.success) { - const classifiedError = await classifyBacklinksErrorWithAccountState( + const classifiedError = classifyBacklinksError( response.status, rawText, path, @@ -121,7 +121,7 @@ async function postBacklinks(path: string, payload: unknown) { const responseData = parsed.data; if (responseData.status_code !== 20000) { - const classifiedError = await classifyBacklinksErrorWithAccountState( + const classifiedError = classifyBacklinksError( responseData.status_code, `${responseData.status_message ?? ""} ${rawText}`, path, @@ -139,7 +139,7 @@ async function postBacklinks(path: string, payload: unknown) { } if (task.status_code !== 20000) { - const classifiedError = await classifyBacklinksErrorWithAccountState( + const classifiedError = classifyBacklinksError( task.status_code, `${task.status_message ?? ""} ${rawText}`, path, diff --git a/src/server/lib/dataforseoBacklinksAccount.ts b/src/server/lib/dataforseoBacklinksAccount.ts deleted file mode 100644 index 3f604df..0000000 --- a/src/server/lib/dataforseoBacklinksAccount.ts +++ /dev/null @@ -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 => { - 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; -} diff --git a/src/server/lib/dataforseoBacklinksSupport.ts b/src/server/lib/dataforseoBacklinksSupport.ts index 5e323a4..8fff3f8 100644 --- a/src/server/lib/dataforseoBacklinksSupport.ts +++ b/src/server/lib/dataforseoBacklinksSupport.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { createDataforseoAccessClassifier } from "@/server/lib/dataforseoAccessClassification"; import { AppError } from "@/server/lib/errors"; const taskResultSchema = z @@ -120,88 +121,15 @@ export const backlinksHistoryItemSchema = z }) .passthrough(); -export function classifyBacklinksError( - status: number | undefined, - details: string, - path: string, -): AppError | null { - const text = details.toLowerCase(); - const looksLikeBacklinksAccessIssue = - path.includes("/backlinks/") && - (text.includes("backlinks") || - text.includes("subscription") || - text.includes("access") || - text.includes("plan") || - text.includes("balance") || - text.includes("payment") || - text.includes("billing") || - text.includes("available") || - text.includes("enabled") || - status === 402 || - status === 403); - - if (!looksLikeBacklinksAccessIssue) return null; - - if (status === 40204) { - return new AppError( - "BACKLINKS_NOT_ENABLED", - "Backlinks is not enabled for the connected DataForSEO account", - ); - } - - if (status === 40200 || status === 40210 || status === 402) { - return new AppError( - "BACKLINKS_BILLING_ISSUE", - "The connected DataForSEO account has a billing or balance issue", - ); - } - - const unavailableSignals = [ - "not available", - "not enabled", - "not allowed", - "access denied", - "forbidden", - "insufficient", - "subscription", - "upgrade", - "plan", - "activate your subscription", - "plans and subscriptions", - ]; - const billingSignals = [ - "payment required", - "billing", - "balance", - "insufficient funds", - "balance is too low", - "problem billing", - "recharged", - ]; - - if (billingSignals.some((signal) => text.includes(signal))) { - return new AppError( - "BACKLINKS_BILLING_ISSUE", - "The connected DataForSEO account has a billing or balance issue", - ); - } - - if (unavailableSignals.some((signal) => text.includes(signal))) { - return new AppError( - "BACKLINKS_NOT_ENABLED", - "Backlinks is not enabled for the connected DataForSEO account", - ); - } - - if (status === 403) { - return new AppError( - "BACKLINKS_NOT_ENABLED", - "Backlinks is not enabled for the connected DataForSEO account", - ); - } - - return null; -} +export const classifyBacklinksError = createDataforseoAccessClassifier({ + pathPrefix: "/backlinks/", + notEnabledCode: "BACKLINKS_NOT_ENABLED", + notEnabledMessage: + "Backlinks is not enabled for the connected DataForSEO account", + billingIssueCode: "BACKLINKS_BILLING_ISSUE", + billingIssueMessage: + "The connected DataForSEO account has a billing or balance issue", +}); export function parseItems( endpointName: string, diff --git a/src/server/lib/dataforseoLlm.ts b/src/server/lib/dataforseoLlm.ts index 8e24a78..b9c3d44 100644 --- a/src/server/lib/dataforseoLlm.ts +++ b/src/server/lib/dataforseoLlm.ts @@ -12,6 +12,7 @@ import { type LlmTopPagesItem, } from "@/server/lib/dataforseoLlmSchemas"; import type { DataforseoApiResponse } from "@/server/lib/dataforseoCost"; +import { createDataforseoAccessClassifier } from "@/server/lib/dataforseoAccessClassification"; import { AppError } from "@/server/lib/errors"; import { getRequiredEnvValue } from "@/server/lib/runtime-env"; @@ -50,9 +51,12 @@ async function postLlm(path: string, payload: unknown): Promise { const rawText = await response.text(); if (!response.ok) { - throw new AppError( - "INTERNAL_ERROR", - `DataForSEO HTTP ${response.status} on ${path}. Response: ${truncate(rawText)}`, + throw ( + classifyAiSearchError(response.status, rawText, path) ?? + new AppError( + "INTERNAL_ERROR", + `DataForSEO HTTP ${response.status} on ${path}. Response: ${truncate(rawText)}`, + ) ); } @@ -66,6 +70,16 @@ async function postLlm(path: string, payload: unknown): Promise { } } +const classifyAiSearchError = createDataforseoAccessClassifier({ + pathPrefix: "/ai_optimization/", + notEnabledCode: "AI_SEARCH_NOT_ENABLED", + notEnabledMessage: + "AI Optimization is not enabled for the connected DataForSEO account", + billingIssueCode: "AI_SEARCH_BILLING_ISSUE", + billingIssueMessage: + "The connected DataForSEO account has a billing or balance issue", +}); + function truncate(text: string): string { return text.length > MAX_DATAFORSEO_ERROR_PAYLOAD_LENGTH ? `${text.slice(0, MAX_DATAFORSEO_ERROR_PAYLOAD_LENGTH)}... [truncated]` @@ -86,9 +100,10 @@ function parseEnvelope(path: string, raw: unknown): LlmDataforseoTask { const data = envelope.data; if (data.status_code !== 20000) { - throw new AppError( - "INTERNAL_ERROR", - data.status_message || `DataForSEO ${path} request failed`, + const message = data.status_message || `DataForSEO ${path} request failed`; + throw ( + classifyAiSearchError(data.status_code, message, path) ?? + new AppError("INTERNAL_ERROR", message) ); } @@ -101,9 +116,10 @@ function parseEnvelope(path: string, raw: unknown): LlmDataforseoTask { } if (task.status_code !== 20000) { - throw new AppError( - "INTERNAL_ERROR", - task.status_message || `DataForSEO ${path} task failed`, + const message = task.status_message || `DataForSEO ${path} task failed`; + throw ( + classifyAiSearchError(task.status_code, message, path) ?? + new AppError("INTERNAL_ERROR", message) ); } diff --git a/src/server/lib/runtime-env.ts b/src/server/lib/runtime-env.ts index 05369d1..eb3d4a2 100644 --- a/src/server/lib/runtime-env.ts +++ b/src/server/lib/runtime-env.ts @@ -26,15 +26,6 @@ export async function isHostedServerAuthMode(): Promise { return isHostedAuthMode(await getEnvValue("AUTH_MODE")); } -export async function getWorkersBinding(name: string): Promise { - const workersEnv = await getWorkersEnv(); - const binding = workersEnv?.[name]; - if (!binding) { - throw new Error(`Missing required Worker binding: ${name}`); - } - return binding; -} - async function getWorkersEnv(): Promise | null> { if (!workersEnvPromise) { workersEnvPromise = loadWorkersEnv(); diff --git a/src/serverFunctions/aiSearchAccess.ts b/src/serverFunctions/aiSearchAccess.ts new file mode 100644 index 0000000..adf2e07 --- /dev/null +++ b/src/serverFunctions/aiSearchAccess.ts @@ -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 => { + 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, + }; + }); diff --git a/src/serverFunctions/backlinks.ts b/src/serverFunctions/backlinks.ts index 7993ac9..5b03535 100644 --- a/src/serverFunctions/backlinks.ts +++ b/src/serverFunctions/backlinks.ts @@ -1,10 +1,5 @@ import { createServerFn } from "@tanstack/react-start"; -import { - buildBacklinksDisabledAccessStatus, - setBacklinksAccessStatus, -} from "@/server/features/backlinks/backlinksAccess"; import { BacklinksService } from "@/server/features/backlinks/services/BacklinksService"; -import { AppError } from "@/server/lib/errors"; import { requireProjectContext } from "@/serverFunctions/middleware"; import { backlinksOverviewInputSchema } from "@/types/schemas/backlinks"; @@ -14,31 +9,20 @@ export const getBacklinksOverview = createServerFn({ .middleware(requireProjectContext) .inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data)) .handler(async ({ data, context }) => { - try { - const input = { - target: data.target, - scope: data.scope, - }; - const spamOptions = { - hideSpam: data.hideSpam, - spamThreshold: data.spamThreshold, - }; - const profile = await BacklinksService.profileOverview( - input, - context, - spamOptions, - ); - return profile.overview; - } catch (error) { - if (error instanceof AppError && error.code === "BACKLINKS_NOT_ENABLED") { - const checkedAt = new Date().toISOString(); - await setBacklinksAccessStatus( - buildBacklinksDisabledAccessStatus(checkedAt, error.code), - ); - } - - throw error; - } + const input = { + target: data.target, + scope: data.scope, + }; + const spamOptions = { + hideSpam: data.hideSpam, + spamThreshold: data.spamThreshold, + }; + const profile = await BacklinksService.profileOverview( + input, + context, + spamOptions, + ); + return profile.overview; }); export const getBacklinksReferringDomains = createServerFn({ @@ -47,20 +31,15 @@ export const getBacklinksReferringDomains = createServerFn({ .middleware(requireProjectContext) .inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data)) .handler(async ({ data, context }) => { - try { - const input = { - target: data.target, - scope: data.scope, - }; - const profile = await BacklinksService.profileReferringDomains( - input, - context, - ); - return profile.rows; - } catch (error) { - await updateBacklinksAccessStatusOnError(error); - throw error; - } + const input = { + target: data.target, + scope: data.scope, + }; + const profile = await BacklinksService.profileReferringDomains( + input, + context, + ); + return profile.rows; }); export const getBacklinksTopPages = createServerFn({ @@ -69,24 +48,10 @@ export const getBacklinksTopPages = createServerFn({ .middleware(requireProjectContext) .inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data)) .handler(async ({ data, context }) => { - try { - const input = { - target: data.target, - scope: data.scope, - }; - const profile = await BacklinksService.profileTopPages(input, context); - return profile.rows; - } catch (error) { - await updateBacklinksAccessStatusOnError(error); - throw error; - } + const input = { + target: data.target, + scope: data.scope, + }; + const profile = await BacklinksService.profileTopPages(input, context); + return profile.rows; }); - -async function updateBacklinksAccessStatusOnError(error: unknown) { - if (error instanceof AppError && error.code === "BACKLINKS_NOT_ENABLED") { - const checkedAt = new Date().toISOString(); - await setBacklinksAccessStatus( - buildBacklinksDisabledAccessStatus(checkedAt, error.code), - ); - } -} diff --git a/src/serverFunctions/backlinksAccess.ts b/src/serverFunctions/backlinksAccess.ts index 95b3c6d..121ff68 100644 --- a/src/serverFunctions/backlinksAccess.ts +++ b/src/serverFunctions/backlinksAccess.ts @@ -1,78 +1,34 @@ import { createServerFn } from "@tanstack/react-start"; import { - buildBacklinksDisabledAccessStatus, - buildVerifiedBacklinksAccessStatus, - getBacklinksAccessStatus, - setBacklinksAccessStatus, -} from "@/server/features/backlinks/backlinksAccess"; -import { AppError } from "@/server/lib/errors"; -import { createDataforseoClient } from "@/server/lib/dataforseoClient"; + fetchDataforseoAccountState, + hasActiveDataforseoSubscription, +} from "@/server/lib/dataforseoAccountState"; import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; import { requireProjectContext } from "@/serverFunctions/middleware"; import { backlinksProjectSchema } from "@/types/schemas/backlinks"; -const BACKLINKS_ACCESS_CHECK_COOLDOWN_MS = 15 * 60 * 1000; +const BACKLINKS_NOT_ENABLED_MESSAGE = + "Backlinks is not enabled for the connected DataForSEO account yet. Turn it on in DataForSEO, then confirm here."; -export const getBacklinksAccessSetupStatus = createServerFn({ - method: "GET", -}) +type BacklinksAccessStatus = { + enabled: boolean; + errorMessage: string | null; +}; + +export const getBacklinksAccessSetupStatus = createServerFn({ method: "GET" }) .middleware(requireProjectContext) .inputValidator((data: unknown) => backlinksProjectSchema.parse(data)) - .handler(async () => getBacklinksAccessStatus()); - -export const testBacklinksAccess = createServerFn({ - method: "POST", -}) - .middleware(requireProjectContext) - .inputValidator((data: unknown) => backlinksProjectSchema.parse(data)) - .handler(async ({ context }) => { + .handler(async (): Promise => { if (await isHostedServerAuthMode()) { - // Hosted deployments do not run the manual DataForSEO access test here; - // backlinks access is treated as platform-managed in this mode. - return getBacklinksAccessStatus(); + return { enabled: true, errorMessage: null }; } - const cachedStatus = await getBacklinksAccessStatus(); - if (isRecentVerifiedBacklinksAccessCheck(cachedStatus)) { - return cachedStatus; - } - - const checkedAt = new Date().toISOString(); - const dataforseo = createDataforseoClient(context); - - try { - await dataforseo.backlinks.summary({ - target: "dataforseo.com", - }); - - const status = buildVerifiedBacklinksAccessStatus(checkedAt); - await setBacklinksAccessStatus(status); - return status; - } catch (error) { - if (error instanceof AppError && error.code === "BACKLINKS_NOT_ENABLED") { - const status = buildBacklinksDisabledAccessStatus( - checkedAt, - error.code, - ); - await setBacklinksAccessStatus(status); - return status; - } - - throw error; - } + const state = await fetchDataforseoAccountState(); + const enabled = hasActiveDataforseoSubscription( + state?.backlinksSubscriptionExpiryDate ?? null, + ); + return { + enabled, + errorMessage: enabled ? null : BACKLINKS_NOT_ENABLED_MESSAGE, + }; }); - -function isRecentVerifiedBacklinksAccessCheck( - status: Awaited>, -) { - 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; -} diff --git a/src/shared/error-codes.ts b/src/shared/error-codes.ts index 984ace3..e3f0a3d 100644 --- a/src/shared/error-codes.ts +++ b/src/shared/error-codes.ts @@ -12,6 +12,8 @@ const ERROR_CODES = [ "CRAWL_TARGET_BLOCKED", "BACKLINKS_NOT_ENABLED", "BACKLINKS_BILLING_ISSUE", + "AI_SEARCH_NOT_ENABLED", + "AI_SEARCH_BILLING_ISSUE", "RATE_LIMITED", "CONFLICT", "INTERNAL_ERROR", diff --git a/src/types/schemas/ai-search.ts b/src/types/schemas/ai-search.ts index 9862878..889564f 100644 --- a/src/types/schemas/ai-search.ts +++ b/src/types/schemas/ai-search.ts @@ -7,6 +7,14 @@ import { z } from "zod"; * by server functions, services, R2 cache validation, and the client UI. */ +// --------------------------------------------------------------------------- +// AI Search access setup (self-hosted mode only) +// --------------------------------------------------------------------------- + +export const aiSearchProjectSchema = z.object({ + projectId: z.string().min(1), +}); + // --------------------------------------------------------------------------- // Brand Lookup // ---------------------------------------------------------------------------