* 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
221 lines
6.4 KiB
TypeScript
221 lines
6.4 KiB
TypeScript
import { z } from "zod";
|
|
import { AppError } from "@/server/lib/errors";
|
|
|
|
const dataforseoTaskSchema = z
|
|
.object({
|
|
status_code: z.number().optional(),
|
|
status_message: z.string().optional(),
|
|
path: z.array(z.string()),
|
|
cost: z.number(),
|
|
result_count: z.number().nullable(),
|
|
result: z
|
|
.array(
|
|
z
|
|
.object({
|
|
items: z.array(z.unknown()).optional(),
|
|
})
|
|
.passthrough(),
|
|
)
|
|
.optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
export type DataforseoTask = z.infer<typeof dataforseoTaskSchema>;
|
|
export const successfulDataforseoTaskSchema = dataforseoTaskSchema;
|
|
|
|
export const dataforseoResponseSchema = z
|
|
.object({
|
|
status_code: z.number().optional(),
|
|
status_message: z.string().optional(),
|
|
tasks: z.array(dataforseoTaskSchema).optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
function getTaskItems(task: DataforseoTask): unknown[] {
|
|
return task.result?.[0]?.items ?? [];
|
|
}
|
|
|
|
const monthlySearchSchema = z
|
|
.object({
|
|
year: z.number().int(),
|
|
month: z.number().int().min(1).max(12),
|
|
search_volume: z.number().nullable(),
|
|
})
|
|
.passthrough();
|
|
|
|
const keywordInfoSchema = z
|
|
.object({
|
|
search_volume: z.number().nullable().optional(),
|
|
cpc: z.number().nullable().optional(),
|
|
competition: z.number().nullable().optional(),
|
|
monthly_searches: z.array(monthlySearchSchema).nullable().optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
const keywordInfoWithClickstreamSchema = z
|
|
.object({
|
|
search_volume: z.number().nullable().optional(),
|
|
monthly_searches: z.array(monthlySearchSchema).nullable().optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
const searchIntentInfoSchema = z
|
|
.object({
|
|
main_intent: z.string().nullable().optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
const keywordPropertiesSchema = z
|
|
.object({
|
|
keyword_difficulty: z.number().nullable().optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
export const relatedKeywordItemSchema = z
|
|
.object({
|
|
keyword_data: z
|
|
.object({
|
|
keyword: z.string().optional(),
|
|
keyword_info: keywordInfoSchema.optional(),
|
|
keyword_info_normalized_with_clickstream:
|
|
keywordInfoWithClickstreamSchema.optional(),
|
|
search_intent_info: searchIntentInfoSchema.nullable().optional(),
|
|
keyword_properties: keywordPropertiesSchema.nullable().optional(),
|
|
})
|
|
.passthrough(),
|
|
})
|
|
.passthrough();
|
|
|
|
export const labsKeywordDataItemSchema = z
|
|
.object({
|
|
keyword: z.string(),
|
|
keyword_info: keywordInfoSchema.optional(),
|
|
keyword_info_normalized_with_clickstream:
|
|
keywordInfoWithClickstreamSchema.optional(),
|
|
search_intent_info: searchIntentInfoSchema.nullable().optional(),
|
|
keyword_properties: keywordPropertiesSchema.nullable().optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
const domainMetricsValueSchema = z
|
|
.object({
|
|
etv: z.number().nullable().optional(),
|
|
count: z.number().nullable().optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
export const domainMetricsItemSchema = z
|
|
.object({
|
|
metrics: z.record(
|
|
z.string(),
|
|
domainMetricsValueSchema.nullable().optional(),
|
|
),
|
|
})
|
|
.passthrough();
|
|
|
|
const rankedKeywordInfoSchema = z
|
|
.object({
|
|
search_volume: z.number().nullable().optional(),
|
|
cpc: z.number().nullable().optional(),
|
|
keyword_difficulty: z.number().nullable().optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
const rankedKeywordDataSchema = z
|
|
.object({
|
|
keyword: z.string().nullable().optional(),
|
|
keyword_info: rankedKeywordInfoSchema.nullable().optional(),
|
|
keyword_properties: keywordPropertiesSchema.nullable().optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
const rankedSerpItemSchema = z
|
|
.object({
|
|
url: z.string().nullable().optional(),
|
|
relative_url: z.string().nullable().optional(),
|
|
rank_absolute: z.number().nullable().optional(),
|
|
etv: z.number().nullable().optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
const rankedSerpElementSchema = z
|
|
.object({
|
|
serp_item: rankedSerpItemSchema.nullable().optional(),
|
|
url: z.string().nullable().optional(),
|
|
relative_url: z.string().nullable().optional(),
|
|
rank_absolute: z.number().nullable().optional(),
|
|
etv: z.number().nullable().optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
export const domainRankedKeywordItemSchema = z
|
|
.object({
|
|
keyword_data: rankedKeywordDataSchema.nullable().optional(),
|
|
ranked_serp_element: rankedSerpElementSchema.nullable().optional(),
|
|
keyword: z.string().nullable().optional(),
|
|
rank_absolute: z.number().nullable().optional(),
|
|
etv: z.number().nullable().optional(),
|
|
keyword_difficulty: z.number().nullable().optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
export const serpSnapshotItemSchema = z
|
|
.object({
|
|
type: z.string(),
|
|
rank_group: z.number().nullable().optional(),
|
|
rank_absolute: z.number().nullable().optional(),
|
|
domain: z.string().nullable().optional(),
|
|
title: z.string().nullable().optional(),
|
|
url: z.string().nullable().optional(),
|
|
description: z.string().nullable().optional(),
|
|
breadcrumb: z.string().nullable().optional(),
|
|
etv: z.number().nullable().optional(),
|
|
estimated_paid_traffic_cost: z.number().nullable().optional(),
|
|
backlinks_info: z
|
|
.object({
|
|
referring_domains: z.number().nullable().optional(),
|
|
backlinks: z.number().nullable().optional(),
|
|
})
|
|
.passthrough()
|
|
.nullable()
|
|
.optional(),
|
|
rank_changes: z
|
|
.object({
|
|
previous_rank_absolute: z.number().nullable().optional(),
|
|
is_new: z.boolean().nullable().optional(),
|
|
is_up: z.boolean().nullable().optional(),
|
|
is_down: z.boolean().nullable().optional(),
|
|
})
|
|
.passthrough()
|
|
.nullable()
|
|
.optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
export type RelatedKeywordItem = z.infer<typeof relatedKeywordItemSchema>;
|
|
export type LabsKeywordDataItem = z.infer<typeof labsKeywordDataItemSchema>;
|
|
export type DomainMetricsItem = z.infer<typeof domainMetricsItemSchema>;
|
|
export type DomainRankedKeywordItem = z.infer<
|
|
typeof domainRankedKeywordItemSchema
|
|
>;
|
|
export type SerpLiveItem = z.infer<typeof serpSnapshotItemSchema>;
|
|
|
|
export function parseTaskItems<T extends z.ZodType>(
|
|
endpointName: string,
|
|
task: DataforseoTask,
|
|
itemSchema: T,
|
|
): z.infer<T>[] {
|
|
const parsed = z.array(itemSchema).safeParse(getTaskItems(task));
|
|
if (!parsed.success) {
|
|
console.error(
|
|
`dataforseo.${endpointName}.invalid-payload`,
|
|
parsed.error.issues.slice(0, 5),
|
|
);
|
|
throw new AppError(
|
|
"INTERNAL_ERROR",
|
|
`DataForSEO ${endpointName} returned an invalid response shape`,
|
|
);
|
|
}
|
|
return parsed.data;
|
|
}
|