* feat: add hosted billing gate and backlinks metering * refine hosted billing onboarding flow * fix: align Autumn credit billing flows * refactor: simplify Autumn billing flow * refactor: narrow backlinks billing context * refactor: require billing identity for backlinks profiles * refactor: colocate backlinks billing flow * clean * refactor: centralize DataForSEO billing client * refactor: simplify dataforseo billing flow * chore: fix ci check lint issues * fix: stabilize backlinks chart sizing Measure chart container width before rendering the backlinks charts so Recharts does not mount at 0x0 inside responsive layouts. * docs: document hosted DataForSEO metering Capture the Autumn credit model and require hosted code to go through the metered client path so provider usage is harder to bypass. * fix: enforce hosted billing access checks * fix: preserve hosted billing subscription access * fix: handle hosted billing UI failures
257 lines
6.8 KiB
TypeScript
257 lines
6.8 KiB
TypeScript
import {
|
|
normalizeDomainInput,
|
|
toRelativePath,
|
|
type DomainRankedKeywordItem,
|
|
} from "@/server/lib/dataforseo";
|
|
import { sortBy } from "remeda";
|
|
import { buildCacheKey, getCached, setCached } from "@/server/lib/kv-cache";
|
|
import { z } from "zod";
|
|
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
|
import { createDataforseoClient } from "@/server/lib/dataforseoClient";
|
|
|
|
/** Domain overview data is refreshed every 12 hours. */
|
|
const DOMAIN_OVERVIEW_TTL_SECONDS = 12 * 60 * 60;
|
|
|
|
type DomainOverviewResult = {
|
|
domain: string;
|
|
organicTraffic: number | null;
|
|
organicKeywords: number | null;
|
|
backlinks: number | null;
|
|
referringDomains: number | null;
|
|
hasData: boolean;
|
|
keywords: Array<{
|
|
keyword: string;
|
|
position: number | null;
|
|
searchVolume: number | null;
|
|
traffic: number | null;
|
|
cpc: number | null;
|
|
url: string | null;
|
|
relativeUrl: string | null;
|
|
keywordDifficulty: number | null;
|
|
}>;
|
|
pages: Array<{
|
|
page: string;
|
|
relativePath: string | null;
|
|
organicTraffic: number | null;
|
|
keywords: number | null;
|
|
backlinks: number | null;
|
|
}>;
|
|
fetchedAt: string;
|
|
};
|
|
|
|
const domainKeywordSchema = z.object({
|
|
keyword: z.string(),
|
|
position: z.number().nullable(),
|
|
searchVolume: z.number().nullable(),
|
|
traffic: z.number().nullable(),
|
|
cpc: z.number().nullable(),
|
|
url: z.string().nullable(),
|
|
relativeUrl: z.string().nullable(),
|
|
keywordDifficulty: z.number().nullable(),
|
|
});
|
|
|
|
const domainPageSchema = z.object({
|
|
page: z.string(),
|
|
relativePath: z.string().nullable(),
|
|
organicTraffic: z.number().nullable(),
|
|
keywords: z.number().nullable(),
|
|
backlinks: z.number().nullable(),
|
|
});
|
|
|
|
const domainOverviewSchema = z.object({
|
|
domain: z.string(),
|
|
organicTraffic: z.number().nullable(),
|
|
organicKeywords: z.number().nullable(),
|
|
backlinks: z.number().nullable(),
|
|
referringDomains: z.number().nullable(),
|
|
hasData: z.boolean(),
|
|
keywords: z.array(domainKeywordSchema),
|
|
pages: z.array(domainPageSchema),
|
|
fetchedAt: z.string(),
|
|
});
|
|
|
|
async function getOverview(
|
|
input: {
|
|
domain: string;
|
|
includeSubdomains: boolean;
|
|
locationCode: number;
|
|
languageCode: string;
|
|
},
|
|
billingCustomer: BillingCustomerContext,
|
|
): Promise<DomainOverviewResult> {
|
|
const domain = normalizeDomainInput(input.domain, input.includeSubdomains);
|
|
|
|
// --- KV cache check ---
|
|
const cacheKey = buildCacheKey("domain:overview", {
|
|
organizationId: billingCustomer.organizationId,
|
|
domain,
|
|
includeSubdomains: input.includeSubdomains,
|
|
locationCode: input.locationCode,
|
|
languageCode: input.languageCode,
|
|
});
|
|
|
|
const cachedRaw = await getCached(cacheKey);
|
|
const cached = domainOverviewSchema.safeParse(cachedRaw);
|
|
if (cached.success && cached.data.hasData) {
|
|
return cached.data;
|
|
}
|
|
|
|
// --- Fetch fresh from DataForSEO ---
|
|
const nowIso = new Date().toISOString();
|
|
const dataforseo = createDataforseoClient(billingCustomer);
|
|
|
|
const [metricsResponse, rankedKeywordsResponse] = await Promise.all([
|
|
dataforseo.domain.rankOverview({
|
|
target: domain,
|
|
locationCode: input.locationCode,
|
|
languageCode: input.languageCode,
|
|
}),
|
|
dataforseo.domain.rankedKeywords({
|
|
target: domain,
|
|
locationCode: input.locationCode,
|
|
languageCode: input.languageCode,
|
|
limit: 200,
|
|
orderBy: ["keyword_data.keyword_info.search_volume,desc"],
|
|
}),
|
|
]);
|
|
|
|
const metrics = metricsResponse[0];
|
|
const rankedItems = rankedKeywordsResponse;
|
|
|
|
const keywords = rankedItems
|
|
.map((item) => mapKeywordItem(item))
|
|
.filter(
|
|
(item): item is NonNullable<ReturnType<typeof mapKeywordItem>> =>
|
|
item != null,
|
|
);
|
|
|
|
const pages = derivePages(keywords);
|
|
|
|
const organicTraffic =
|
|
metrics?.metrics?.organic?.etv != null
|
|
? Math.round(metrics.metrics.organic.etv)
|
|
: null;
|
|
const organicKeywords =
|
|
metrics?.metrics?.organic?.count != null
|
|
? Math.round(metrics.metrics.organic.count)
|
|
: null;
|
|
|
|
const result: DomainOverviewResult = {
|
|
domain,
|
|
organicTraffic,
|
|
organicKeywords,
|
|
backlinks: null,
|
|
referringDomains: null,
|
|
hasData: keywords.length > 0,
|
|
keywords,
|
|
pages,
|
|
fetchedAt: nowIso,
|
|
};
|
|
|
|
// Persist to KV (fire-and-forget; don't block response)
|
|
if (result.hasData) {
|
|
void setCached(cacheKey, result, DOMAIN_OVERVIEW_TTL_SECONDS).catch(
|
|
(error) => {
|
|
console.error("domain.overview.cache-write failed:", error);
|
|
},
|
|
);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function mapKeywordItem(item: DomainRankedKeywordItem) {
|
|
const keywordData = item.keyword_data;
|
|
const keywordInfo = keywordData?.keyword_info;
|
|
const keywordProperties = keywordData?.keyword_properties;
|
|
const rankedSerpElement = item.ranked_serp_element;
|
|
const serpItem = rankedSerpElement?.serp_item;
|
|
|
|
const keyword = keywordData?.keyword ?? item.keyword;
|
|
if (!keyword) return null;
|
|
|
|
const url = serpItem?.url ?? rankedSerpElement?.url ?? null;
|
|
|
|
const relativeUrl =
|
|
serpItem?.relative_url ??
|
|
rankedSerpElement?.relative_url ??
|
|
(url ? toRelativePath(url) : null);
|
|
|
|
const position =
|
|
serpItem?.rank_absolute ?? rankedSerpElement?.rank_absolute ?? null;
|
|
|
|
const traffic = serpItem?.etv ?? rankedSerpElement?.etv ?? null;
|
|
|
|
const keywordDifficulty =
|
|
keywordProperties?.keyword_difficulty ??
|
|
keywordInfo?.keyword_difficulty ??
|
|
null;
|
|
|
|
return {
|
|
keyword,
|
|
position: position != null ? Math.round(position) : null,
|
|
searchVolume:
|
|
keywordInfo?.search_volume != null
|
|
? Math.round(keywordInfo.search_volume)
|
|
: null,
|
|
traffic: traffic ?? null,
|
|
cpc: keywordInfo?.cpc ?? null,
|
|
url: url ?? null,
|
|
relativeUrl,
|
|
keywordDifficulty:
|
|
keywordDifficulty != null ? Math.round(keywordDifficulty) : null,
|
|
};
|
|
}
|
|
|
|
function derivePages(
|
|
keywords: Array<{
|
|
url: string | null;
|
|
relativeUrl: string | null;
|
|
traffic: number | null;
|
|
}>,
|
|
) {
|
|
const grouped = new Map<
|
|
string,
|
|
{
|
|
page: string;
|
|
relativePath: string | null;
|
|
traffic: number;
|
|
keywords: number;
|
|
}
|
|
>();
|
|
|
|
for (const keyword of keywords) {
|
|
if (!keyword.url) continue;
|
|
|
|
const existing = grouped.get(keyword.url) ?? {
|
|
page: keyword.url,
|
|
relativePath: keyword.relativeUrl,
|
|
traffic: 0,
|
|
keywords: 0,
|
|
};
|
|
|
|
existing.traffic += keyword.traffic ?? 0;
|
|
existing.keywords += 1;
|
|
|
|
grouped.set(keyword.url, existing);
|
|
}
|
|
|
|
return sortBy(Array.from(grouped.values()), [(page) => page.traffic, "desc"])
|
|
.slice(0, 100)
|
|
.map((page) => ({
|
|
page: page.page,
|
|
relativePath: page.relativePath,
|
|
organicTraffic: page.traffic,
|
|
keywords: page.keywords,
|
|
backlinks: null,
|
|
}));
|
|
}
|
|
|
|
export const DomainService = {
|
|
getOverview,
|
|
} as const;
|