import { z } from "zod"; import type { BillingCustomerContext } from "@/server/billing/subscription"; import { createDataforseoClient } from "@/server/lib/dataforseo"; import { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache"; import { normalizeDomainInput, toRelativePath } from "@/server/lib/domainUtils"; import type { RelevantPagesItem } from "@/server/lib/dataforseo"; import type { DomainKeywordsFilters } from "@/types/schemas/domain"; const DOMAIN_PAGES_PAGE_TTL_SECONDS = 12 * 60 * 60; type DomainPagesSortMode = "traffic" | "keywords"; type DomainPagesSortOrder = "asc" | "desc"; const SORT_FIELD_BY_MODE: Record = { traffic: "metrics.organic.etv", keywords: "metrics.organic.count", }; const domainPagesPageResultSchema = z.object({ domain: z.string(), page: z.number(), pageSize: z.number(), totalCount: z.number().nullable(), hasMore: z.boolean(), pages: z.array( z.object({ page: z.string(), relativePath: z.string().nullable(), organicTraffic: z.number().nullable(), keywords: z.number().nullable(), }), ), fetchedAt: z.string(), }); type DomainPagesPageResult = z.infer; function escapeLikeTerm(term: string): string { return term.replace(/[\\%_]/g, (match) => `\\${match}`); } function pushAnd(filters: unknown[], expression: unknown[]) { if (filters.length > 0) filters.push("and"); filters.push(expression); } function collectNumericRange( out: unknown[][], field: string, min: number | undefined, max: number | undefined, ) { if (typeof min === "number" && Number.isFinite(min)) { out.push([field, ">=", min]); } if (typeof max === "number" && Number.isFinite(max)) { out.push([field, "<=", max]); } } function parseTerms(value: string | undefined): string[] { if (!value) return []; return value .toLowerCase() .split(/[,+]/) .map((term) => term.trim()) .filter(Boolean); } function buildPageFilters( filters: DomainKeywordsFilters, searchTerm?: string, ): unknown[] { const conditions: unknown[][] = []; for (const term of parseTerms(filters.include)) { conditions.push(["page_address", "ilike", `%${escapeLikeTerm(term)}%`]); } for (const term of parseTerms(filters.exclude)) { conditions.push(["page_address", "not_ilike", `%${escapeLikeTerm(term)}%`]); } collectNumericRange( conditions, "metrics.organic.etv", filters.minTraffic, filters.maxTraffic, ); collectNumericRange( conditions, "metrics.organic.count", filters.minVol, filters.maxVol, ); const trimmed = searchTerm?.trim(); if (trimmed) { conditions.push(["page_address", "ilike", `%${escapeLikeTerm(trimmed)}%`]); } const expressions: unknown[] = []; for (const condition of conditions) pushAnd(expressions, condition); return expressions; } function mapPageItem(item: RelevantPagesItem) { const url = item.page_address ?? null; if (!url) return null; const organic = item.metrics?.organic ?? null; const traffic = organic?.etv ?? null; const keywords = organic?.count ?? null; return { page: url, relativePath: toRelativePath(url), organicTraffic: traffic != null ? Math.round(traffic) : null, keywords: keywords != null ? Math.round(keywords) : null, }; } export async function getPagesPage( input: { projectId: string; domain: string; includeSubdomains: boolean; locationCode: number; languageCode: string; page: number; pageSize: number; sortMode: DomainPagesSortMode; sortOrder: DomainPagesSortOrder; filters: DomainKeywordsFilters; search?: string; }, billingCustomer: BillingCustomerContext, ): Promise { const domain = normalizeDomainInput(input.domain, input.includeSubdomains); const offset = (input.page - 1) * input.pageSize; const orderBy = [`${SORT_FIELD_BY_MODE[input.sortMode]},${input.sortOrder}`]; const filters = buildPageFilters(input.filters, input.search); const cacheKey = await buildCacheKey("domain:pages-page", { organizationId: billingCustomer.organizationId, projectId: input.projectId, domain, includeSubdomains: input.includeSubdomains, locationCode: input.locationCode, languageCode: input.languageCode, page: input.page, pageSize: input.pageSize, sortMode: input.sortMode, sortOrder: input.sortOrder, filters: input.filters, search: input.search, }); const cachedRaw = await getCached(cacheKey); const cached = domainPagesPageResultSchema.safeParse(cachedRaw); if (cached.success) { return cached.data; } const dataforseo = createDataforseoClient(billingCustomer); const response = await dataforseo.domain.relevantPages({ target: domain, locationCode: input.locationCode, languageCode: input.languageCode, limit: input.pageSize, offset, orderBy, filters: filters.length > 0 ? filters : undefined, }); const pages = response.items .map(mapPageItem) .filter( (item): item is NonNullable> => item != null, ); const totalCount = response.totalCount; const hasMore = totalCount != null ? offset + pages.length < totalCount : pages.length === input.pageSize; const result: DomainPagesPageResult = { domain, page: input.page, pageSize: input.pageSize, totalCount, hasMore, pages, fetchedAt: new Date().toISOString(), }; void setCached(cacheKey, result, DOMAIN_PAGES_PAGE_TTL_SECONDS).catch( (error) => { console.error("domain.pages-page.cache-write failed:", error); }, ); return result; }