import type { BillingCustomerContext } from "@/server/billing/subscription"; import { ActivationRepository } from "@/server/features/activation/repositories/ActivationRepository"; import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository"; import { getIssueTypePageCountsForAudit } from "@/server/features/audit/repositories/auditSummaryQueries"; import { BacklinkSnapshotRepository } from "@/server/features/dashboard/repositories/BacklinkSnapshotRepository"; import { Ga4ConnectionRepository } from "@/server/features/ga4/repositories/Ga4ConnectionRepository"; import { GscConnectionRepository } from "@/server/features/gsc/repositories/GscConnectionRepository"; import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository"; import { getLatestResults } from "@/server/features/rank-tracking/services/rankTrackingResults"; import { createDataforseoClient, normalizeBacklinksTarget, } from "@/server/lib/dataforseo"; // Daily cadence: fresh numbers each visit without per-visit spend; a dormant // project costs nothing because refreshes are visit-triggered. const SNAPSHOT_MAX_AGE_MS = 24 * 60 * 60 * 1000; // Bounds the per-config result reads on the overview path; projects rarely // have more than a couple of configs. const MAX_CONFIGS_FOR_OVERVIEW = 5; export type DashboardActivation = { domain: string | null; ga4: { connected: boolean; propertyDisplayName: string | null; cardDismissedAt: string | null; }; gsc: { connected: boolean; siteUrl: string | null }; mcp: { authorizedAt: string | null; firstToolCallAt: string | null; cardDismissedAt: string | null; }; competitorClickedAt: string | null; }; type DashboardRankSummary = { trackedKeywords: number; improved: number; declined: number; top10: number; lastCheckedAt: string | null; }; export type DashboardAuditSummary = { status: "running" | "completed" | "failed"; pagesCrawled: number; startedAt: string; // Top issue types by severity then affected-page count, for the card's list. topIssues: { issueType: string; severity: "critical" | "warning" | "info"; count: number; }[]; totalIssueTypes: number; }; export type DashboardBacklinkSummary = { domain: string; rank: number | null; backlinks: number | null; referringDomains: number | null; newBacklinks: number | null; lostBacklinks: number | null; newReferringDomains: number | null; lostReferringDomains: number | null; capturedAt: string; stale: boolean; }; type DashboardOverview = { rank: DashboardRankSummary | null; audit: DashboardAuditSummary | null; backlinks: DashboardBacklinkSummary | null; }; async function getActivation(input: { projectId: string; organizationId: string; domain: string | null; }): Promise { const [ga4, gsc, orgActivation, projectActivation] = await Promise.all([ Ga4ConnectionRepository.getByProjectId(input.projectId), GscConnectionRepository.getByProjectId(input.projectId), ActivationRepository.getOrganizationActivation(input.organizationId), ActivationRepository.getProjectActivation(input.projectId), ]); return { domain: input.domain, ga4: { connected: ga4 !== null, propertyDisplayName: ga4?.propertyDisplayName ?? null, cardDismissedAt: projectActivation?.ga4CardDismissedAt ?? null, }, gsc: { connected: gsc !== null, siteUrl: gsc?.siteUrl ?? null }, mcp: { authorizedAt: orgActivation?.firstMcpAuthorizedAt ?? null, firstToolCallAt: orgActivation?.firstMcpToolCallAt ?? null, cardDismissedAt: projectActivation?.mcpCardDismissedAt ?? null, }, competitorClickedAt: projectActivation?.competitorStepClickedAt ?? null, }; } async function getOverview(input: { projectId: string; domain: string | null; }): Promise { const [rank, audit, backlinks] = await Promise.all([ getRankSummary(input.projectId), getAuditSummary(input.projectId), getBacklinkSummary(input.projectId, input.domain), ]); return { rank, audit, backlinks }; } async function getRankSummary( projectId: string, ): Promise { const configs = await RankTrackingRepository.getConfigsForProject(projectId); if (configs.length === 0) return null; const results = await Promise.all( configs .slice(0, MAX_CONFIGS_FOR_OVERVIEW) .map((config) => getLatestResults(config.id, projectId, "7d")), ); const summary: DashboardRankSummary = { trackedKeywords: 0, improved: 0, declined: 0, top10: 0, lastCheckedAt: null, }; for (const result of results) { summary.trackedKeywords += result.rows.length; if ( result.run?.lastCheckedAt && (!summary.lastCheckedAt || result.run.lastCheckedAt > summary.lastCheckedAt) ) { summary.lastCheckedAt = result.run.lastCheckedAt; } for (const row of result.rows) { for (const device of ["desktop", "mobile"] as const) { const { position, previousPosition } = row[device]; if (position !== null && position <= 10) summary.top10 += 1; if (position === null || previousPosition === null) continue; // Lower position number = better ranking. if (position < previousPosition) summary.improved += 1; else if (position > previousPosition) summary.declined += 1; } } } return summary; } async function getAuditSummary( projectId: string, ): Promise { const audit = await AuditRepository.getLatestAuditForProject(projectId); if (!audit) return null; const typeRows = await getIssueTypePageCountsForAudit(audit.id); const severityRank = { critical: 0, warning: 1, info: 2 }; const sorted = typeRows .map((row) => ({ issueType: row.issueType, severity: row.severity, count: row.pages, })) .toSorted( (a, b) => severityRank[a.severity] - severityRank[b.severity] || b.count - a.count, ); return { status: audit.status, pagesCrawled: audit.pagesCrawled, startedAt: audit.startedAt, topIssues: sorted.slice(0, 3), totalIssueTypes: sorted.length, }; } function isSnapshotFresh(capturedAt: string): boolean { const capturedMs = Date.parse(capturedAt); if (Number.isNaN(capturedMs)) return false; return Date.now() - capturedMs < SNAPSHOT_MAX_AGE_MS; } async function getBacklinkSummary( projectId: string, domain: string | null, ): Promise { if (!domain) return null; const snapshot = await BacklinkSnapshotRepository.getLatestForProject(projectId); if (!snapshot || snapshot.domain !== domain) return null; return { domain: snapshot.domain, rank: snapshot.rank, backlinks: snapshot.backlinks, referringDomains: snapshot.referringDomains, newBacklinks: snapshot.newBacklinks, lostBacklinks: snapshot.lostBacklinks, newReferringDomains: snapshot.newReferringDomains, lostReferringDomains: snapshot.lostReferringDomains, capturedAt: snapshot.capturedAt, stale: !isSnapshotFresh(snapshot.capturedAt), }; } /** * Visit-triggered snapshot refresh. Fetches only the DataForSEO backlinks * summary (not the history endpoint the backlinks page also pays for) and is * a no-op while the latest snapshot for the current domain is under a day * old. Concurrent loads racing the freshness check can each pay a metered * call — every call is metered, so the race duplicates customer spend on * identical data but never leaks revenue; accepted for now. On a fetch * failure with a stale snapshot in hand, the stale snapshot is returned * rather than surfacing an error card. */ async function ensureBacklinkSnapshot(input: { projectId: string; domain: string | null; billingCustomer: BillingCustomerContext; }): Promise { const { projectId, domain } = input; if (!domain) return null; const latest = await BacklinkSnapshotRepository.getLatestForProject(projectId); const latestMatchesDomain = latest !== null && latest.domain === domain; if (latest && latestMatchesDomain && isSnapshotFresh(latest.capturedAt)) { return getBacklinkSummary(projectId, domain); } // Dashboard totals cover the whole site, subdomains included. const normalized = normalizeBacklinksTarget(domain, { scope: "subdomains" }); const dataforseo = createDataforseoClient(input.billingCustomer); try { const summary = await dataforseo.backlinks.summary({ target: normalized.apiTarget, includeSubdomains: normalized.includeSubdomains, }); await BacklinkSnapshotRepository.insert({ projectId, domain, rank: summary.rank ?? null, backlinks: summary.backlinks ?? null, referringDomains: summary.referring_domains ?? null, brokenBacklinks: summary.broken_backlinks ?? null, newBacklinks: summary.new_backlinks ?? null, lostBacklinks: summary.lost_backlinks ?? null, newReferringDomains: summary.new_referring_domains ?? summary.new_reffering_domains ?? null, lostReferringDomains: summary.lost_referring_domains ?? summary.lost_reffering_domains ?? null, capturedAt: new Date().toISOString(), }); } catch (error) { if (latestMatchesDomain) { console.error("dashboard: backlink snapshot refresh failed", error); return getBacklinkSummary(projectId, domain); } throw error; } return getBacklinkSummary(projectId, domain); } export const DashboardService = { getActivation, getOverview, ensureBacklinkSnapshot, };