Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 73 additions & 15 deletions src/app/[locale]/u/[username]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,15 @@ import {
getSimilarAccounts,
getUserMatchups,
} from "@/lib/db";
import { getCachedScan } from "@/lib/redis";
import {
beginStaleScoreRefresh,
coalesceScan,
deleteCachedScan,
finishStaleScoreRefresh,
getCachedScan,
} from "@/lib/redis";
import { buildScanResult } from "@/lib/scan-core";
import { recordDeterministicScan } from "@/lib/score-persist";
import { aggregateLanguages, collectTopics } from "@/lib/profile-insights";
import { PendingProfile } from "./PendingProfile";
import { LiveRoast } from "@/components/LiveRoast";
Expand Down Expand Up @@ -57,8 +65,32 @@ export const dynamic = "force-dynamic";

// Dedupe the DB read between generateMetadata() and the page render.
const getDetail = cache((username: string) => getAccountDetail(username));
const getStaleDetail = cache((username: string) =>
getAccountDetail(username, { includeStale: true }),
);
// Dedupe the cached-scan read (pending-profile fallback) across the same pair.
const getLiveScan = cache((username: string) => getCachedScan(username));
async function requestIp(): Promise<string> {
const h = await headers();
const fwd = h.get("x-forwarded-for");
return fwd?.split(",")[0]?.trim() || h.get("x-real-ip") || "0.0.0.0";
}

const refreshStaleDetail = cache(async (username: string, ip: string) => {
const guard = await beginStaleScoreRefresh(username, ip);
if (!guard.allowed) return null;
try {
const scan = await coalesceScan(username, () => buildScanResult(username));
await recordDeterministicScan(scan);
await deleteCachedScan(scan.metrics.username);
return getAccountDetail(scan.metrics.username);
} catch (e) {
console.error("stale score refresh failed:", e);
return null;
} finally {
await finishStaleScoreRefresh(username);
}
});

export async function generateMetadata({
params,
Expand All @@ -83,6 +115,13 @@ export async function generateMetadata({
robots: { index: false, follow: true },
};
}
const stale = await getStaleDetail(decoded);
if (stale) {
return {
title: t("staleTitle", { username: stale.username }),
robots: { index: false, follow: true },
};
}
return { title: t("notFoundTitle") };
}

Expand Down Expand Up @@ -133,7 +172,8 @@ export default async function AccountPage({
const { locale, username } = await params;
setRequestLocale(locale);
const decoded = decodeURIComponent(username);
const d = await getDetail(decoded);
let d = await getDetail(decoded);
let staleFallback = false;
if (!d) {
// First-time username being roasted right now: no `scores` row yet. Render
// the live pending shell when we have a scan to show — either the server-side
Expand All @@ -142,8 +182,17 @@ export default async function AccountPage({
// completion. Otherwise it's a genuine unknown handle → 404.
const scan = await getLiveScan(decoded);
const roasting = (await searchParams)?.roasting === "1";
if (!scan && !roasting) notFound();
return <PendingProfile username={decoded} initialScan={scan ?? null} />;
if (!scan && !roasting) {
const stale = await getStaleDetail(decoded);
if (!stale) notFound();
d = await refreshStaleDetail(stale.username, await requestIp());
if (!d) {
d = stale;
staleFallback = true;
}
} else {
return <PendingProfile username={decoded} initialScan={scan ?? null} />;
}
}

const t = await getTranslations("detail");
Expand All @@ -162,17 +211,19 @@ export default async function AccountPage({
// Row exists but this language's roast is missing (e.g. an English visitor on a
// zh-only roast). If the scan is still cached, stream a live roast in the
// report slot instead of the "run a roast" empty state.
const liveScan = !roast && !roastLine ? await getLiveScan(d.username) : null;
const [similar, comments, snap, rank, session, battles, facetRank] =
await Promise.all([
getSimilarAccounts(d.username, d.final_score, d.sub_scores),
getProfileComments(d.username),
getProfileSnapshot(d.username),
getRank(d.final_score),
authConfigured() ? auth() : Promise.resolve(null),
getUserMatchups(d.username),
getFacetRank(d.username, d.final_score),
]);
const liveScan =
!staleFallback && !roast && !roastLine ? await getLiveScan(d.username) : null;
const [similar, comments, snap, rank, session, battles, facetRank] = await Promise.all([
staleFallback
? Promise.resolve([])
: getSimilarAccounts(d.username, d.final_score, d.sub_scores),
getProfileComments(d.username),
getProfileSnapshot(d.username, { includeStale: staleFallback }),
staleFallback ? Promise.resolve(null) : getRank(d.final_score),
authConfigured() ? auth() : Promise.resolve(null),
getUserMatchups(d.username),
staleFallback ? Promise.resolve(null) : getFacetRank(d.username, d.final_score),
]);
// Inline re-detect is self-service: only the signed-in owner sees it on their
// own profile. GitHub handles are case-insensitive, so compare normalized.
const isOwner =
Expand Down Expand Up @@ -257,6 +308,13 @@ export default async function AccountPage({
<BadgeReferralBanner owner={d.username} signal={badgeSignal!} />
)}

{staleFallback && (
<section className="mt-4 rounded-2xl border border-orange-300/30 bg-orange-500/[0.08] p-4 text-sm leading-6 text-zinc-200 shadow-[0_18px_60px_rgba(0,0,0,0.18)]">
<div className="font-bold text-orange-300">{t("staleBadge")}</div>
<p className="mt-1 text-zinc-300">{t("staleBody")}</p>
</section>
)}

<div className="mt-4 flex flex-col gap-6 lg:flex-row lg:items-start">
{/* Left: sticky identity sidebar — score stays visible while reading */}
<aside className="flex flex-col gap-4 lg:sticky lg:top-8 lg:w-80 lg:shrink-0">
Expand Down
5 changes: 5 additions & 0 deletions src/app/api/score/[username]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { TIER_KEY } from "@/lib/tier";
import { SITE_URL } from "@/lib/site";
import { checkRateLimit, coalesceScan, getCachedScan } from "@/lib/redis";
import { buildScanResult, scanErrorResponse } from "@/lib/scan-core";
import { recordDeterministicScan } from "@/lib/score-persist";
import type { ScanResult, Tier } from "@/lib/types";

export const runtime = "nodejs";
Expand Down Expand Up @@ -95,6 +96,7 @@ export async function GET(
}

// 2) Not indexed → score it live, deterministically (NO LLM).
const staleDetail = await getAccountDetail(handle, { includeStale: true });
const cached = await getCachedScan(handle);
if (!cached) {
const { success } = await checkRateLimit(clientIp(req));
Expand All @@ -115,6 +117,9 @@ export async function GET(
if (!cached) {
await recordAccountLookup(result.metrics.username, clientIp(req));
}
if (staleDetail) {
await recordDeterministicScan(result);
}

const s = result.scoring;
const m = result.metrics;
Expand Down
152 changes: 151 additions & 1 deletion src/lib/__tests__/db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { createClient } from "@libsql/client";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { ROAST_CACHE_VERSION } from "../cache-version";
import { ROAST_CACHE_VERSION, SCORE_CACHE_VERSION } from "../cache-version";
import type { ScoreEntry } from "../db";
import type { ScanResult } from "../types";

let db: typeof import("../db");
let persist: typeof import("../score-persist");
let tmpDir: string;

const entry: ScoreEntry = {
Expand Down Expand Up @@ -35,6 +37,7 @@ beforeAll(async () => {
process.env.TURSO_DATABASE_URL = `file:${join(tmpDir, "test.db")}`;
delete process.env.TURSO_AUTH_TOKEN;
db = await import("../db");
persist = await import("../score-persist");
});

afterAll(() => {
Expand Down Expand Up @@ -89,6 +92,153 @@ describe("getArchivedRoast", () => {
});
});

describe("current score reads", () => {
it("does not expose stale score rows as current account details", async () => {
await db.recordScore({ ...entry, username: "stale-score" });

const client = createClient({ url: process.env.TURSO_DATABASE_URL! });
await client.execute({
sql: `UPDATE scores SET score_version = ? WHERE username = ?`,
args: [`${SCORE_CACHE_VERSION}-old`, "stale-score"],
});

await expect(db.getAccountDetail("stale-score")).resolves.toBeNull();
await expect(
db.getAccountDetail("stale-score", { includeStale: true }),
).resolves.toMatchObject({
username: "stale-score",
final_score: entry.final_score,
});
});

it("does not expose stale profile snapshots as current evidence", async () => {
const client = createClient({ url: process.env.TURSO_DATABASE_URL! });
await client.execute({
sql: `INSERT INTO profile_snapshots
(id, username, scanned_at, top_repos, impact_repos, verified_prs,
metrics, pinned_repos, organizations, scan_version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
"stale-snapshot-id",
"stale-snapshot",
1,
"[]",
"[]",
"[]",
"{}",
"[]",
"[]",
`${SCORE_CACHE_VERSION}-old`,
],
});

await expect(db.getProfileSnapshot("stale-snapshot")).resolves.toBeNull();
await expect(
db.getProfileSnapshot("stale-snapshot", { includeStale: true }),
).resolves.toMatchObject({ scanned_at: 1 });
});

it("excludes stale score rows from public score surfaces", async () => {
await db.recordScore({ ...entry, username: "current-public", final_score: 91 });
await db.recordScore({ ...entry, username: "stale-public", final_score: 100 });

const client = createClient({ url: process.env.TURSO_DATABASE_URL! });
await client.execute({
sql: `UPDATE scores SET score_version = ? WHERE username = ?`,
args: [`${SCORE_CACHE_VERSION}-old`, "stale-public"],
});

const board = await db.getLeaderboard(20, 90);
expect(board.map((e) => e.username)).toContain("current-public");
expect(board.map((e) => e.username)).not.toContain("stale-public");

await expect(db.getScoreBrief("stale-public")).resolves.toBeNull();
await expect(db.searchScoredUsers("stale-public")).resolves.toEqual([]);
});

it("can refresh a stale score row with deterministic scan output", async () => {
await db.recordScore({ ...entry, username: "auto-refresh", final_score: 40 });

const client = createClient({ url: process.env.TURSO_DATABASE_URL! });
await client.execute({
sql: `UPDATE scores SET score_version = ? WHERE username = ?`,
args: [`${SCORE_CACHE_VERSION}-old`, "auto-refresh"],
});

const scan: ScanResult = {
metrics: {
username: "auto-refresh",
profile_url: "https://github.com/auto-refresh",
avatar_url: null,
name: "Auto Refresh",
bio: null,
company: null,
account_age_years: 3,
created_at: "2023-01-01T00:00:00Z",
followers: 2,
following: 0,
public_repos: 1,
fetched_repo_count: 1,
original_repo_count: 1,
nonempty_original_repo_count: 1,
fork_repo_count: 0,
empty_original_repo_count: 0,
total_stars: 10,
max_stars: 10,
merged_pr_count: 2,
total_pr_count: 2,
issues_created: 0,
last_year_contributions: 10,
activity_type_count: 2,
contribution_years_active: 2,
days_since_last_activity: 1,
recent_merged_pr_sample: 2,
recent_trivial_pr_count: 0,
external_trivial_pr_count: 0,
max_impact_repo_stars: 0,
impact_pr_count: 0,
impact_depth_raw: 0,
star_inflation_suspect: false,
closed_unmerged_pr_count: 0,
pr_rejection_rate: 0,
recent_pr_sample: 2,
top_repo_pr_target: null,
top_repo_pr_share: 0,
templated_pr_ratio: 0,
pr_flood_suspect: false,
},
top_repos: [],
recent_prs: [],
flood_pr_titles: [],
impact_repos: [],
verified_impact_prs: [],
pinned_repos: [],
organizations: [],
scoring: {
sub_scores: entry.sub_scores,
base_score: 73.5,
red_flags: [],
total_penalty: 0,
final_score: 73.5,
tier: "人上人",
tier_label: "人上人",
},
};

await persist.recordDeterministicScan(scan, entry.scanned_at + 1);

await expect(db.getAccountDetail("auto-refresh")).resolves.toMatchObject({
username: "auto-refresh",
final_score: 73.5,
roast_line: { zh: "", en: "" },
});
await expect(db.getProfileSnapshot("auto-refresh")).resolves.toMatchObject({
top_repos: [],
metrics: expect.objectContaining({ total_stars: 10, merged_pr_count: 2 }),
});
});
});

describe("score snapshots", () => {
it("stores one generated-at stub when a completed roast is persisted", async () => {
const username = "roast-snapshot";
Expand Down
21 changes: 21 additions & 0 deletions src/lib/__tests__/redis.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { afterEach, describe, expect, it, vi } from "vitest";

describe("stale score refresh guard", () => {
afterEach(() => {
vi.unstubAllEnvs();
});

it("fails closed when Redis is not configured", async () => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("UPSTASH_REDIS_REST_URL", undefined);
vi.stubEnv("UPSTASH_REDIS_REST_TOKEN", undefined);
vi.resetModules();

const { beginStaleScoreRefresh } = await import("../redis");

await expect(beginStaleScoreRefresh("stale-user", "203.0.113.9")).resolves.toEqual({
allowed: false,
reason: "redis_unavailable",
});
});
});
Loading