metatron-open-seo/src/server/lib/dataforseoBacklinksAccount.ts
Ben Senescu 8405564925
feat: Add Backlinks page (#23)
* feat: add backlinks setup and overview workflow

* save

* fix backlinks timeseries and refine overview layout

* update backlinks table guidance

* refactor backlinks CI cleanup

* refactor backlinks page state colocation

* fix backlinks access error handling

Differentiate DataForSEO subscription access failures from billing issues and preserve exact page hosts for page-level backlink lookups.

* fix backlinks project scoping and error states

* simplify backlinks billing and lazy loading

* harden backlinks profiling and access flow

* refine backlinks access status typing

* update backlinks scope selection and access handling

* fix backlinks scope fallback and cache scoping

* fix backlinks ci checks

* docs: simplify backlinks pricing

* refine backlinks search: move scope toggle below input, remove redundant description

* refine backlinks summaries and help text

* refine backlinks table sorting

* fix backlinks invalid target handling

* delete docs

* fix backlinks scope inference and domain normalization

* docs: mark backlinks as a supported workflow

* fix backlinks links badge wrapping

* test: split backlinks test suites
2026-03-16 17:48:13 -04:00

155 lines
3.9 KiB
TypeScript

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;
}