Remove the RankMovementCard (setup pitch + rank movement stats) from the dashboard grid until the rank tracking experience gets more polish. The overview endpoint still returns the rank summary, so restoring the card is a small revert of this commit.
274 lines
8.9 KiB
TypeScript
274 lines
8.9 KiB
TypeScript
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 { 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;
|
|
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<DashboardActivation> {
|
|
const [gsc, orgActivation, projectActivation] = await Promise.all([
|
|
GscConnectionRepository.getByProjectId(input.projectId),
|
|
ActivationRepository.getOrganizationActivation(input.organizationId),
|
|
ActivationRepository.getProjectActivation(input.projectId),
|
|
]);
|
|
|
|
return {
|
|
domain: input.domain,
|
|
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<DashboardOverview> {
|
|
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<DashboardRankSummary | null> {
|
|
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 &&
|
|
(!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<DashboardAuditSummary | null> {
|
|
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<DashboardBacklinkSummary | null> {
|
|
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<DashboardBacklinkSummary | null> {
|
|
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);
|
|
}
|
|
|
|
const normalized = normalizeBacklinksTarget(domain, { scope: "domain" });
|
|
const dataforseo = createDataforseoClient(input.billingCustomer);
|
|
|
|
try {
|
|
const summary = await dataforseo.backlinks.summary({
|
|
target: normalized.apiTarget,
|
|
});
|
|
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,
|
|
};
|