diff --git a/src/app/api/leaderboard/route.ts b/src/app/api/leaderboard/route.ts index 4da370f..d316219 100644 --- a/src/app/api/leaderboard/route.ts +++ b/src/app/api/leaderboard/route.ts @@ -16,7 +16,14 @@ export async function GET(request: Request) { try { const result = await getLeaderboardResult(country); - return NextResponse.json({ success: true, ...result }); + return NextResponse.json( + { success: true, ...result }, + { + headers: { + "Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400", + }, + }, + ); } catch (err) { console.error("Leaderboard DB query failed:", err); diff --git a/src/app/api/user/[username]/route.ts b/src/app/api/user/[username]/route.ts index 4a04a84..0b0fabc 100644 --- a/src/app/api/user/[username]/route.ts +++ b/src/app/api/user/[username]/route.ts @@ -20,7 +20,14 @@ export async function GET(request: Request, { params }: { params: Promise<{ user try { const { user, location } = await getUserProfile(trimmed, selectedLanguages); - return NextResponse.json({ success: true, user, location }); + return NextResponse.json( + { success: true, user, location }, + { + headers: { + "Cache-Control": "public, s-maxage=1800, stale-while-revalidate=86400", + }, + }, + ); } catch (error: unknown) { console.error("User profile fetch error:", error); return formatApiErrorResponse(error); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d5744d8..7adb0f4 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -99,7 +99,7 @@ export const metadata: Metadata = { export default async function RootLayout({ children }: { children: ReactNode }) { const cookieStore = await cookies(); const headerStore = await headers(); - const cookieLocale = cookieStore.get(LOCALE_COOKIE)?.value; + const cookieLocale = cookieStore.get(LOCALE_COOKIE)?.value || headerStore.get("x-locale"); const initialLocale = isSupportedLocale(cookieLocale) ? cookieLocale : parseAcceptLanguage(headerStore.get("accept-language"), supportedLocales, DEFAULT_LOCALE); diff --git a/src/app/leaderboard/[country]/page.tsx b/src/app/leaderboard/[country]/page.tsx index c6a191b..b5cf525 100644 --- a/src/app/leaderboard/[country]/page.tsx +++ b/src/app/leaderboard/[country]/page.tsx @@ -91,8 +91,13 @@ export async function generateMetadata({ params }: Props): Promise { }; } +export const revalidate = 3600; // Cache on Vercel Edge CDN for 1 hour via ISR +export const dynamicParams = true; + export async function generateStaticParams() { - return countries.map((country) => ({ country: country.slug })); + // Pre-render the top 20 most visited countries at build time. + // Other countries are generated on-demand upon first visit and cached by ISR. + return countries.slice(0, 20).map((country) => ({ country: country.slug })); } export default async function CountryLeaderboardPage({ params }: Props) { diff --git a/src/app/scoring-methodology/page.tsx b/src/app/scoring-methodology/page.tsx index 591daf1..b17878b 100644 --- a/src/app/scoring-methodology/page.tsx +++ b/src/app/scoring-methodology/page.tsx @@ -1,3 +1,4 @@ +import { Suspense } from "react"; import type { Metadata } from "next"; import { JsonLd } from "@/components/seo/json-ld"; import { ScoringMethodologyPageClient } from "@/features/scoring"; @@ -68,7 +69,9 @@ export default function ScoringMethodologyPage() { return ( <> - + }> + + ); } diff --git a/src/app/user/[username]/page.tsx b/src/app/user/[username]/page.tsx index a371828..61731dd 100644 --- a/src/app/user/[username]/page.tsx +++ b/src/app/user/[username]/page.tsx @@ -21,6 +21,9 @@ type CountryInfo = { const countries = countriesData as CountryInfo[]; +export const revalidate = 1800; // Cache user profile on Edge CDN for 30 minutes via ISR +export const dynamicParams = true; + type Props = { params: Promise<{ username: string }>; searchParams?: Promise<{ country?: string }>; diff --git a/src/features/comparison/services/compare-service.ts b/src/features/comparison/services/compare-service.ts index 22cb86a..98c2323 100644 --- a/src/features/comparison/services/compare-service.ts +++ b/src/features/comparison/services/compare-service.ts @@ -1,6 +1,5 @@ -import { getUserData } from "@/lib/github"; -import { calculateUserScore, normalizeSelectedLanguages } from "@/features/scoring"; -import { persistUserScores } from "@/features/developer/services"; +import { normalizeSelectedLanguages } from "@/features/scoring"; +import { getUserProfile, UserFetchError } from "@/features/developer/services"; import { DEFAULT_LOCALE, LOCALE_COOKIE, @@ -8,7 +7,6 @@ import { parseAcceptLanguage, type Locale, } from "@/lib/i18n/core"; -import type { GitHubUserData } from "@/lib/github"; import type { ComparedUserResult, CompareInsights, CompareWinner, LanguageWinner } from "../types"; export class CompareUserFetchError extends Error { @@ -257,53 +255,36 @@ export async function compareUsers( usernames: string[], selectedLanguages: string[], ): Promise { - return Promise.all( - usernames.map(async (username) => { - let data: GitHubUserData; - try { - const { data: userData } = await getUserData(username, { - cacheInRedis: true, - withMetrics: true, - }); - data = userData; - } catch (error: unknown) { - throw new CompareUserFetchError(username, error); - } - - const score = calculateUserScore( - { - ...data, - selectedLanguages, - }, + const results: ComparedUserResult[] = []; + for (const username of usernames) { + try { + const { user } = await getUserProfile(username, selectedLanguages); + results.push({ + username: user.username, + name: user.name, + avatarUrl: user.avatarUrl, + repoScore: user.repoScore, + prScore: user.prScore, + contributionScore: user.contributionScore, + finalScore: user.finalScore, + normalizedRepoScore: user.normalizedRepoScore ?? user.repoScore, + normalizedPRScore: user.normalizedPRScore ?? user.prScore, + normalizedContributionScore: user.normalizedContributionScore ?? user.contributionScore, + normalizedFinalScore: user.normalizedFinalScore ?? user.finalScore, + topRepos: user.topRepos as ComparedUserResult["topRepos"], + topPullRequests: user.topPullRequests as ComparedUserResult["topPullRequests"], + topCommunityContributions: (user.topCommunityContributions ?? + []) as ComparedUserResult["topCommunityContributions"], + languageScores: user.languageScores as ComparedUserResult["languageScores"], + signals: user.signals, + explanations: user.explanations, + } as unknown as ComparedUserResult); + } catch (error: unknown) { + throw new CompareUserFetchError( username, + error instanceof UserFetchError ? error.causeError : error, ); - - // Fire-and-forget: detect country & persist canonical scores into DB - void persistUserScores({ - data, - score, - selectedLanguages, - }); - - return { - username: data.login, - name: data.name, - avatarUrl: data.avatarUrl, - repoScore: Math.round(score.repoScore), - prScore: Math.round(score.prScore), - contributionScore: Math.round(score.contributionScore), - finalScore: Math.round(score.finalScore), - normalizedRepoScore: Math.round(score.normalizedRepoScore), - normalizedPRScore: Math.round(score.normalizedPRScore), - normalizedContributionScore: Math.round(score.normalizedContributionScore), - normalizedFinalScore: Math.round(score.normalizedFinalScore), - topRepos: score.topRepos, - topPullRequests: score.topPullRequests, - topCommunityContributions: score.topCommunityContributions, - languageScores: score.languageScores, - signals: score.signals, - explanations: score.explanations, - }; - }), - ); + } + } + return results; } diff --git a/src/features/developer/services/index.ts b/src/features/developer/services/index.ts index 3c1fdfc..fec844e 100644 --- a/src/features/developer/services/index.ts +++ b/src/features/developer/services/index.ts @@ -1,2 +1,3 @@ export * from "./user-service"; export * from "./user-persistence"; +export * from "./profile-cache"; diff --git a/src/features/developer/services/profile-cache.ts b/src/features/developer/services/profile-cache.ts new file mode 100644 index 0000000..79421f1 --- /dev/null +++ b/src/features/developer/services/profile-cache.ts @@ -0,0 +1,74 @@ +import { createCacheStore, getCacheConfigFromEnv, type CacheStore } from "@/lib/cache"; +import type { UserProfileResponse } from "../types"; + +let cacheStore: CacheStore | undefined; + +function getStore(): CacheStore { + if (!cacheStore) { + cacheStore = createCacheStore(getCacheConfigFromEnv()); + } + return cacheStore; +} + +export function buildProfileCacheKey(username: string, selectedLanguages: string[] = []): string { + const config = getCacheConfigFromEnv(); + const normalizedUser = username.trim().toLowerCase(); + if (!selectedLanguages || selectedLanguages.length === 0) { + return `${config.namespace}:profile:${normalizedUser}`; + } + const langKey = selectedLanguages + .map((l) => l.trim().toLowerCase()) + .filter(Boolean) + .sort() + .join(","); + return `${config.namespace}:profile:${normalizedUser}:${langKey}`; +} + +export async function getCachedProfile( + username: string, + selectedLanguages: string[] = [], +): Promise { + const store = getStore(); + if (!store.enabled) { + return null; + } + try { + const key = buildProfileCacheKey(username, selectedLanguages); + const cached = await store.get(key); + return cached ?? null; + } catch { + return null; + } +} + +export async function setCachedProfile( + username: string, + profile: UserProfileResponse, + selectedLanguages: string[] = [], + ttlSeconds?: number, +): Promise { + const store = getStore(); + if (!store.enabled) { + return; + } + try { + const key = buildProfileCacheKey(username, selectedLanguages); + const ttl = ttlSeconds ?? getCacheConfigFromEnv().ttlSeconds; + await store.set(key, profile, ttl); + } catch { + // Non-fatal + } +} + +export async function invalidateProfileCache(username: string): Promise { + const store = getStore(); + if (!store.enabled || !store.del) { + return; + } + try { + const key = buildProfileCacheKey(username, []); + await store.del(key); + } catch { + // Non-fatal + } +} diff --git a/src/features/developer/services/user-service.ts b/src/features/developer/services/user-service.ts index 43caaed..12f757b 100644 --- a/src/features/developer/services/user-service.ts +++ b/src/features/developer/services/user-service.ts @@ -1,6 +1,8 @@ import { getUserData } from "@/lib/github"; import { calculateUserScore } from "@/features/scoring"; +import type { CalculateUserScoreResult } from "@/features/scoring/services"; import { persistUserScores } from "./user-persistence"; +import { getCachedProfile, setCachedProfile } from "./profile-cache"; import type { UserProfileResponse, UserResult } from "../types"; import type { GitHubUserData } from "@/lib/github"; @@ -25,6 +27,65 @@ export async function getUserProfile( throw new Error("Username is required"); } + // ── 1. Check Redis Profile Cache (Tier 1: Pre-computed Profile) ── + try { + const cached = await getCachedProfile(normalizedUsername, selectedLanguages); + if (cached) { + return cached; + } + } catch { + // Non-fatal: continue to DB / fresh calculation + } + + // ── 2. Check PostgreSQL Pre-calculated Scores (Tier 2 for Canonical) ── + const isCanonical = selectedLanguages.length === 0; + if (isCanonical) { + try { + const { getDatabaseStore } = await import("@/lib/db"); + const db = getDatabaseStore(); + if (typeof db.getUserProfile === "function") { + const row = await db.getUserProfile(normalizedUsername); + + if (row && row.scores && row.stale_after > new Date()) { + const scores = row.scores as CalculateUserScoreResult; + const profile: UserProfileResponse = { + user: { + username: row.username, + name: row.name, + avatarUrl: row.avatar_url, + repoScore: row.repo_score, + prScore: row.pr_score, + contributionScore: row.contribution_score, + finalScore: row.final_score, + normalizedRepoScore: Math.round(scores.normalizedRepoScore ?? scores.repoScore), + normalizedPRScore: Math.round(scores.normalizedPRScore ?? scores.prScore), + normalizedContributionScore: Math.round( + scores.normalizedContributionScore ?? scores.contributionScore, + ), + normalizedFinalScore: Math.round(scores.normalizedFinalScore ?? scores.finalScore), + topRepos: scores.topRepos ?? [], + topPullRequests: scores.topPullRequests ?? [], + topCommunityContributions: scores.topCommunityContributions ?? [], + languageScores: scores.languageScores, + signals: scores.signals, + explanations: scores.explanations, + scoreVersion: process.env.DEVIMPACT_VERSION || undefined, + }, + location: row.location ?? null, + }; + + // Warm Redis profile cache + void setCachedProfile(normalizedUsername, profile, selectedLanguages); + + return profile; + } + } + } catch { + // Non-fatal: fall through to full calculation + } + } + + // ── 3. Fallback: Fetch raw GitHub data & compute score ────────────── let data: GitHubUserData; try { const { data: userData } = await getUserData(normalizedUsername, { @@ -65,15 +126,20 @@ export async function getUserProfile( scoreVersion: process.env.DEVIMPACT_VERSION || undefined, }; - // Fire-and-forget: detect country & persist canonical scores into DB + const response: UserProfileResponse = { + user, + location: data.location ?? null, + }; + + // Warm Redis profile cache with the computed profile + void setCachedProfile(normalizedUsername, response, selectedLanguages); + + // Persist canonical scores into DB void persistUserScores({ data, score, selectedLanguages, }); - return { - user, - location: data.location ?? null, - }; + return response; } diff --git a/src/features/developer/tests/profile-cache.test.ts b/src/features/developer/tests/profile-cache.test.ts new file mode 100644 index 0000000..2973b58 --- /dev/null +++ b/src/features/developer/tests/profile-cache.test.ts @@ -0,0 +1,196 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + cacheGet: vi.fn(), + cacheSet: vi.fn(), + cacheDel: vi.fn(), + getUserData: vi.fn(), + calculateUserScore: vi.fn(), + getUserProfile: vi.fn(), + upsertUser: vi.fn(), +})); + +vi.mock("@/lib/cache", () => ({ + getCacheConfigFromEnv: () => ({ + enabled: true, + namespace: "devimpact:v1", + ttlSeconds: 604800, + }), + createCacheStore: () => ({ + enabled: true, + get: mocks.cacheGet, + set: mocks.cacheSet, + del: mocks.cacheDel, + }), +})); + +vi.mock("@/lib/github", () => ({ + getUserData: mocks.getUserData, +})); + +vi.mock("@/features/scoring", () => ({ + calculateUserScore: mocks.calculateUserScore, +})); + +vi.mock("@/lib/db", () => ({ + getDatabaseStore: () => ({ + getUserProfile: mocks.getUserProfile, + upsertUser: mocks.upsertUser, + }), +})); + +import { + buildProfileCacheKey, + getCachedProfile, + setCachedProfile, + invalidateProfileCache, +} from "../services/profile-cache"; +import { getUserProfile } from "../services/user-service"; + +describe("Profile & Score Caching", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("buildProfileCacheKey", () => { + test("builds canonical key for user", () => { + const key = buildProfileCacheKey(" Torvalds "); + expect(key).toBe("devimpact:v1:profile:torvalds"); + }); + + test("builds language-sorted key when selectedLanguages provided", () => { + const key = buildProfileCacheKey("torvalds", ["Rust", "Python", "Go"]); + expect(key).toBe("devimpact:v1:profile:torvalds:go,python,rust"); + }); + }); + + describe("getCachedProfile & setCachedProfile", () => { + test("retrieves cached profile from store", async () => { + const mockProfile = { + user: { + username: "torvalds", + name: "Linus Torvalds", + avatarUrl: "https://example.com/avatar.png", + repoScore: 100, + prScore: 100, + contributionScore: 100, + finalScore: 100, + topRepos: [], + topPullRequests: [], + }, + location: "Helsinki, Finland", + }; + + mocks.cacheGet.mockResolvedValueOnce(mockProfile); + + const result = await getCachedProfile("torvalds"); + expect(mocks.cacheGet).toHaveBeenCalledWith("devimpact:v1:profile:torvalds"); + expect(result).toEqual(mockProfile); + }); + + test("stores profile with TTL", async () => { + const mockProfile = { + user: { + username: "torvalds", + name: "Linus", + avatarUrl: "https://example.com/avatar.png", + repoScore: 90, + prScore: 90, + contributionScore: 90, + finalScore: 90, + topRepos: [], + topPullRequests: [], + }, + location: null, + }; + + await setCachedProfile("torvalds", mockProfile); + expect(mocks.cacheSet).toHaveBeenCalledWith( + "devimpact:v1:profile:torvalds", + mockProfile, + 604800, + ); + }); + + test("invalidates profile cache", async () => { + await invalidateProfileCache("torvalds"); + expect(mocks.cacheDel).toHaveBeenCalledWith("devimpact:v1:profile:torvalds"); + }); + }); + + describe("getUserProfile tiered retrieval", () => { + test("Tier 1: returns directly from Redis cache without calling DB or GitHub", async () => { + const cachedProfile = { + user: { + username: "torvalds", + name: "Linus", + avatarUrl: "https://example.com/avatar.png", + repoScore: 95, + prScore: 95, + contributionScore: 95, + finalScore: 95, + topRepos: [], + topPullRequests: [], + }, + location: "Finland", + }; + + mocks.cacheGet.mockResolvedValueOnce(cachedProfile); + + const result = await getUserProfile("torvalds"); + + expect(result).toEqual(cachedProfile); + // Zero calls to DB, GitHub, or Scoring engine + expect(mocks.getUserProfile).not.toHaveBeenCalled(); + expect(mocks.getUserData).not.toHaveBeenCalled(); + expect(mocks.calculateUserScore).not.toHaveBeenCalled(); + }); + + test("Tier 2: returns from PostgreSQL pre-calculated scores when Redis misses", async () => { + mocks.cacheGet.mockResolvedValueOnce(null); + + mocks.getUserProfile.mockResolvedValueOnce({ + username: "torvalds", + name: "Linus Torvalds", + avatar_url: "https://example.com/avatar.png", + location: "Finland", + country: "Finland", + repo_score: 92, + pr_score: 92, + contribution_score: 92, + final_score: 92, + scores: { + repoScore: 92, + prScore: 92, + contributionScore: 92, + finalScore: 92, + normalizedRepoScore: 90, + normalizedPRScore: 90, + normalizedContributionScore: 90, + normalizedFinalScore: 90, + topRepos: [{ name: "linux", score: 99 }], + topPullRequests: [], + topCommunityContributions: [], + }, + stale_after: new Date(Date.now() + 86400000), // fresh for 1 day + }); + + const result = await getUserProfile("torvalds"); + + expect(result.user.username).toBe("torvalds"); + expect(result.user.finalScore).toBe(92); + expect(result.location).toBe("Finland"); + + // Warmed Redis profile cache + expect(mocks.cacheSet).toHaveBeenCalledWith( + "devimpact:v1:profile:torvalds", + expect.objectContaining({ location: "Finland" }), + 604800, + ); + + // GitHub and score calculation were NOT called + expect(mocks.getUserData).not.toHaveBeenCalled(); + expect(mocks.calculateUserScore).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/features/leaderboard/services/calculate-leaderboard.ts b/src/features/leaderboard/services/calculate-leaderboard.ts index d1a2aa4..68b15dc 100644 --- a/src/features/leaderboard/services/calculate-leaderboard.ts +++ b/src/features/leaderboard/services/calculate-leaderboard.ts @@ -54,6 +54,29 @@ function getRefreshLimit(): number { return getEnvInt("LEADERBOARD_REFRESH_LIMIT", 500); } +function getConcurrency(): number { + return getEnvInt("LEADERBOARD_CONCURRENCY", 3); +} + +async function runConcurrent( + items: T[], + concurrency: number, + task: (item: T) => Promise, +): Promise { + if (items.length === 0) return; + const limit = Math.max(1, Math.min(concurrency, items.length)); + let currentIndex = 0; + + const workers = Array.from({ length: limit }, async () => { + while (currentIndex < items.length) { + const index = currentIndex++; + await task(items[index]); + } + }); + + await Promise.all(workers); +} + function getSourceUrl(country: string): string { const template = process.env.LEADERBOARD_SOURCE_URL_TEMPLATE?.trim(); if (!template) { @@ -92,6 +115,7 @@ export async function seedNewUsers( users: LeaderboardSourceEntry[], seedLimit: number, staleDays: number, + concurrency: number = getConcurrency(), ): Promise<{ newUsersCount: number; skippedExistingCount: number; @@ -104,15 +128,19 @@ export async function seedNewUsers( const fetchMetrics: { duration: number; errors: { part: string; reason: string }[] }[] = []; const usersToSeed = users.slice(0, seedLimit); + const existingSet = await db.getExistingUsernames(usersToSeed.map((u) => u.login)); + const usersToFetch: LeaderboardSourceEntry[] = []; for (const user of usersToSeed) { - try { - const exists = await db.userExists(user.login); - if (exists) { - skippedExistingCount += 1; - continue; - } + if (existingSet.has(user.login.toLowerCase())) { + skippedExistingCount += 1; + } else { + usersToFetch.push(user); + } + } + await runConcurrent(usersToFetch, concurrency, async (user) => { + try { const { data, metrics } = await getUserData(user.login, { cacheInRedis: false, withMetrics: true, @@ -130,7 +158,7 @@ export async function seedNewUsers( } catch (e) { errors.push({ username: user.login, reason: e instanceof Error ? e.message : String(e) }); } - } + }); return { newUsersCount, skippedExistingCount, errors, fetchMetrics }; } @@ -140,6 +168,7 @@ export async function refreshStaleUsers( country: string, refreshLimit: number, staleDays: number, + concurrency: number = getConcurrency(), ): Promise<{ refreshedCount: number; errors: { username: string; reason: string }[]; @@ -150,12 +179,10 @@ export async function refreshStaleUsers( const fetchMetrics: { duration: number; errors: { part: string; reason: string }[] }[] = []; const topUsers = await db.getTopUsers(country, refreshLimit); + const now = new Date(); + const staleUsers = topUsers.filter((row) => row.stale_after < now); - for (const row of topUsers) { - if (row.stale_after >= new Date()) { - continue; - } - + await runConcurrent(staleUsers, concurrency, async (row) => { try { const { data, metrics } = await getUserData(row.username, { cacheInRedis: false, @@ -174,7 +201,7 @@ export async function refreshStaleUsers( } catch (e) { errors.push({ username: row.username, reason: e instanceof Error ? e.message : String(e) }); } - } + }); return { refreshedCount, errors, fetchMetrics }; } @@ -242,23 +269,24 @@ export async function calculateLeaderboard( refreshLimit?: number; staleDays?: number; displayLimit?: number; + concurrency?: number; }, ): Promise { const db = getDatabaseStore(); - await db.initializeSchema(); const staleDays = overrides?.staleDays ?? getStaleDays(); const seedLimit = overrides?.seedLimit ?? getSeedLimit(); const refreshLimit = overrides?.refreshLimit ?? getRefreshLimit(); + const concurrency = overrides?.concurrency ?? getConcurrency(); // 1. Fetch source users const sourceData = await fetchCommittersFromTop(country); // 2a. Seed new users - const seedResult = await seedNewUsers(db, sourceData.users, seedLimit, staleDays); + const seedResult = await seedNewUsers(db, sourceData.users, seedLimit, staleDays, concurrency); // 2b. Refresh stale users from DB top N - const refreshResult = await refreshStaleUsers(db, country, refreshLimit, staleDays); + const refreshResult = await refreshStaleUsers(db, country, refreshLimit, staleDays, concurrency); // 3. Build leaderboard result const allErrors = [ diff --git a/src/features/leaderboard/tests/calculate-leaderboard.test.ts b/src/features/leaderboard/tests/calculate-leaderboard.test.ts new file mode 100644 index 0000000..dbdd17a --- /dev/null +++ b/src/features/leaderboard/tests/calculate-leaderboard.test.ts @@ -0,0 +1,203 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getUserData: vi.fn(), + calculateUserScore: vi.fn(), + persistUserScores: vi.fn().mockResolvedValue(undefined), + initializeSchema: vi.fn().mockResolvedValue(undefined), + getExistingUsernames: vi.fn().mockResolvedValue(new Set()), + getTopUsers: vi.fn().mockResolvedValue([]), + getLeaderboard: vi.fn().mockResolvedValue([]), + getLeaderboardCount: vi.fn().mockResolvedValue(0), +})); + +vi.mock("@/lib/github", () => ({ + getUserData: mocks.getUserData, +})); + +vi.mock("@/features/scoring", () => ({ + calculateUserScore: mocks.calculateUserScore, +})); + +vi.mock("@/features/developer/services", () => ({ + persistUserScores: mocks.persistUserScores, +})); + +vi.mock("@/lib/db", () => ({ + getDatabaseStore: () => ({ + initializeSchema: mocks.initializeSchema, + getExistingUsernames: mocks.getExistingUsernames, + getTopUsers: mocks.getTopUsers, + getLeaderboard: mocks.getLeaderboard, + getLeaderboardCount: mocks.getLeaderboardCount, + }), +})); + +vi.mock("@/lib/cache", () => ({ + getCacheConfigFromEnv: () => ({ enabled: false, namespace: "test" }), + createCacheStore: () => ({ enabled: false, del: vi.fn() }), +})); + +import type { DatabaseStore } from "@/lib/db"; +import { + seedNewUsers, + refreshStaleUsers, + calculateLeaderboard, +} from "../services/calculate-leaderboard"; + +describe("calculate-leaderboard service optimizations", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("seedNewUsers", () => { + test("batches username existence checks and skips existing users", async () => { + const mockDb = { + getExistingUsernames: vi.fn().mockResolvedValue(new Set(["alice"])), + } as unknown as DatabaseStore; + + mocks.getUserData.mockResolvedValue({ + data: { login: "bob" }, + metrics: { duration: 12, errors: [] }, + }); + mocks.calculateUserScore.mockReturnValue({ finalScore: 85 }); + + const users = [ + { + rank: 1, + name: "Alice", + login: "alice", + avatarUrl: "https://example.com/1.png", + contributions: 100, + }, + { + rank: 2, + name: "Bob", + login: "bob", + avatarUrl: "https://example.com/2.png", + contributions: 50, + }, + ]; + + const result = await seedNewUsers(mockDb, users, 10, 30, 2); + + // getExistingUsernames was called once with both logins + expect(mockDb.getExistingUsernames).toHaveBeenCalledTimes(1); + expect(mockDb.getExistingUsernames).toHaveBeenCalledWith(["alice", "bob"]); + + // Alice was skipped, Bob was fetched + expect(result.skippedExistingCount).toBe(1); + expect(result.newUsersCount).toBe(1); + expect(mocks.getUserData).toHaveBeenCalledTimes(1); + expect(mocks.getUserData).toHaveBeenCalledWith("bob", { + cacheInRedis: false, + withMetrics: true, + }); + expect(mocks.persistUserScores).toHaveBeenCalledTimes(1); + }); + + test("handles errors for individual users without failing the batch", async () => { + const mockDb = { + getExistingUsernames: vi.fn().mockResolvedValue(new Set()), + } as unknown as DatabaseStore; + + mocks.getUserData + .mockRejectedValueOnce(new Error("Rate limit exceeded")) + .mockResolvedValueOnce({ + data: { login: "user2" }, + metrics: { duration: 10, errors: [] }, + }); + mocks.calculateUserScore.mockReturnValue({ finalScore: 90 }); + + const users = [ + { + rank: 1, + name: "User 1", + login: "user1", + avatarUrl: "https://example.com/1.png", + contributions: 10, + }, + { + rank: 2, + name: "User 2", + login: "user2", + avatarUrl: "https://example.com/2.png", + contributions: 20, + }, + ]; + + const result = await seedNewUsers(mockDb, users, 10, 30, 2); + + expect(result.newUsersCount).toBe(1); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toEqual({ + username: "user1", + reason: "Rate limit exceeded", + }); + }); + }); + + describe("refreshStaleUsers", () => { + test("only refreshes users whose stale_after is in the past", async () => { + const mockDb = { + getTopUsers: vi.fn().mockResolvedValue([ + { username: "staleUser", stale_after: new Date(Date.now() - 10000), final_score: 80 }, + { username: "freshUser", stale_after: new Date(Date.now() + 100000), final_score: 90 }, + ]), + } as unknown as DatabaseStore; + + mocks.getUserData.mockResolvedValue({ + data: { login: "staleUser" }, + metrics: { duration: 15, errors: [] }, + }); + mocks.calculateUserScore.mockReturnValue({ finalScore: 85 }); + + const result = await refreshStaleUsers(mockDb, "sweden", 10, 30, 2); + + expect(result.refreshedCount).toBe(1); + expect(mocks.getUserData).toHaveBeenCalledTimes(1); + expect(mocks.getUserData).toHaveBeenCalledWith("staleUser", { + cacheInRedis: false, + withMetrics: true, + }); + expect(mocks.persistUserScores).toHaveBeenCalledTimes(1); + }); + }); + + describe("calculateLeaderboard", () => { + test("does NOT call db.initializeSchema during execution", async () => { + process.env.LEADERBOARD_SOURCE_URL_TEMPLATE = "https://example.com/{country}.yml"; + + const sampleYaml = ` +title: Sweden +total_user_count: 1 +users: + - rank: 1 + name: Tester + login: tester + avatarUrl: https://example.com/tester.png + contributions: 10 +`; + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue(sampleYaml), + }), + ); + + mocks.getExistingUsernames.mockResolvedValue(new Set(["tester"])); + mocks.getTopUsers.mockResolvedValue([]); + mocks.getLeaderboard.mockResolvedValue([]); + mocks.getLeaderboardCount.mockResolvedValue(1); + + await calculateLeaderboard("sweden"); + + // Verify lock-eliminating fix: initializeSchema must NOT be called + expect(mocks.initializeSchema).not.toHaveBeenCalled(); + expect(mocks.getExistingUsernames).toHaveBeenCalledWith(["tester"]); + + vi.unstubAllGlobals(); + }); + }); +}); diff --git a/src/lib/db/db-store.ts b/src/lib/db/db-store.ts index ba7c12f..c83eaf0 100644 --- a/src/lib/db/db-store.ts +++ b/src/lib/db/db-store.ts @@ -23,6 +23,34 @@ export type GitHubUserRow; + +export type UserProfileRow = Pick< + GitHubUserRow, + | "username" + | "name" + | "avatar_url" + | "location" + | "country" + | "scores" + | "repo_score" + | "pr_score" + | "contribution_score" + | "final_score" + | "stale_after" +>; + +export type TopStaleUserRow = Pick; + export type UpsertUserParams = { username: string; name: string | null; @@ -54,11 +82,13 @@ function getPoolConfig(): PoolConfig { ); } + const isServerless = Boolean(process.env.VERCEL || process.env.AWS_LAMBDA_FUNCTION_NAME); + return { connectionString, - max: 10, - idleTimeoutMillis: 30_000, - connectionTimeoutMillis: 5_000, + max: isServerless ? 2 : 10, + idleTimeoutMillis: isServerless ? 10_000 : 30_000, + connectionTimeoutMillis: 10_000, }; } @@ -112,6 +142,9 @@ export class DatabaseStore { CREATE INDEX IF NOT EXISTS idx_github_users_country_score ON github_users(country, final_score DESC); + CREATE INDEX IF NOT EXISTS idx_github_users_final_score + ON github_users(final_score DESC); + CREATE INDEX IF NOT EXISTS idx_github_users_stale ON github_users(stale_after) WHERE country IS NOT NULL; @@ -241,7 +274,26 @@ export class DatabaseStore { async getUser(username: string): Promise { const client = getPool(); const result = await client.query( - "SELECT * FROM github_users WHERE LOWER(username) = LOWER($1)", + `SELECT + username, name, avatar_url, location, country, + raw_data, scores, repo_score, pr_score, contribution_score, final_score, + fetched_at, stale_after, created_at, updated_at + FROM github_users + WHERE LOWER(username) = LOWER($1)`, + [username], + ); + return result.rows[0] ?? null; + } + + async getUserProfile(username: string): Promise { + const client = getPool(); + const result = await client.query( + `SELECT + username, name, avatar_url, location, country, + scores, repo_score, pr_score, contribution_score, final_score, + stale_after + FROM github_users + WHERE LOWER(username) = LOWER($1)`, [username], ); return result.rows[0] ?? null; @@ -256,31 +308,58 @@ export class DatabaseStore { return (result.rowCount ?? 0) > 0; } - // ── Leaderboard operations ────────────────────────────────────────── + async getExistingUsernames(usernames: string[]): Promise> { + if (!usernames.length) { + return new Set(); + } + + const lowerUsernames = Array.from( + new Set(usernames.map((u) => u.trim().toLowerCase()).filter(Boolean)), + ); + + if (lowerUsernames.length === 0) { + return new Set(); + } - async getLeaderboard(country: string, limit: number = 500): Promise { const client = getPool(); - const result = await client.query( - `SELECT * - FROM ( - SELECT DISTINCT ON (LOWER(username)) * - FROM github_users - WHERE country = $1 - ORDER BY LOWER(username), final_score DESC, updated_at DESC - ) deduped_users - ORDER BY final_score DESC - LIMIT $2`, - [country, limit], + const result = await client.query<{ username: string }>( + "SELECT LOWER(username) AS username FROM github_users WHERE LOWER(username) = ANY($1::text[])", + [lowerUsernames], ); + + return new Set(result.rows.map((r) => r.username.toLowerCase())); + } + + // ── Leaderboard operations ────────────────────────────────────────── + + async getLeaderboard(country: string, limit: number = 500): Promise { + const client = getPool(); + const isWorldwide = country.trim().toLowerCase() === "worldwide"; + const result = isWorldwide + ? await client.query( + `SELECT username, name, avatar_url, repo_score, pr_score, contribution_score, final_score + FROM github_users + ORDER BY final_score DESC + LIMIT $1`, + [limit], + ) + : await client.query( + `SELECT username, name, avatar_url, repo_score, pr_score, contribution_score, final_score + FROM github_users + WHERE country = $1 + ORDER BY final_score DESC + LIMIT $2`, + [country, limit], + ); return result.rows; } async getLeaderboardCount(country: string): Promise { const client = getPool(); - const result = await client.query( - "SELECT COUNT(DISTINCT LOWER(username)) FROM github_users WHERE country = $1", - [country], - ); + const isWorldwide = country.trim().toLowerCase() === "worldwide"; + const result = isWorldwide + ? await client.query("SELECT COUNT(*) FROM github_users") + : await client.query("SELECT COUNT(*) FROM github_users WHERE country = $1", [country]); return Number(result.rows[0].count); } @@ -288,16 +367,12 @@ export class DatabaseStore { * Returns stale users in a country, ordered by score descending. * These are users whose data needs to be refreshed from GitHub. */ - async getTopStaleUsers(country: string, limit: number = 500): Promise { + async getTopStaleUsers(country: string, limit: number = 500): Promise { const client = getPool(); const result = await client.query( - `SELECT * - FROM ( - SELECT DISTINCT ON (LOWER(username)) * - FROM github_users - WHERE country = $1 AND stale_after < NOW() - ORDER BY LOWER(username), final_score DESC, updated_at DESC - ) deduped_users + `SELECT username, stale_after, final_score + FROM github_users + WHERE country = $1 AND stale_after < NOW() ORDER BY final_score DESC LIMIT $2`, [country, limit], @@ -309,16 +384,12 @@ export class DatabaseStore { * Returns the top-scoring users in a country regardless of staleness. * Used to determine which users to check for refresh. */ - async getTopUsers(country: string, limit: number = 500): Promise { + async getTopUsers(country: string, limit: number = 500): Promise { const client = getPool(); const result = await client.query( - `SELECT * - FROM ( - SELECT DISTINCT ON (LOWER(username)) * - FROM github_users - WHERE country = $1 - ORDER BY LOWER(username), final_score DESC, updated_at DESC - ) deduped_users + `SELECT username, stale_after, final_score + FROM github_users + WHERE country = $1 ORDER BY final_score DESC LIMIT $2`, [country, limit], diff --git a/src/lib/db/tests/db-store.test.ts b/src/lib/db/tests/db-store.test.ts new file mode 100644 index 0000000..e16a452 --- /dev/null +++ b/src/lib/db/tests/db-store.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const mockQuery = vi.fn(); + +vi.mock("pg", () => { + class MockPool { + query = mockQuery; + on = vi.fn(); + } + return { + Pool: MockPool, + }; +}); + +// Set dummy DATABASE_URL so getPoolConfig does not throw +process.env.DATABASE_URL = "postgresql://dummy:dummy@localhost:5432/dummydb"; + +import { DatabaseStore } from "../db-store"; + +describe("DatabaseStore.getExistingUsernames", () => { + let store: DatabaseStore; + + beforeEach(() => { + vi.clearAllMocks(); + store = new DatabaseStore(); + }); + + test("returns empty Set immediately when input array is empty without querying database", async () => { + const result = await store.getExistingUsernames([]); + expect(result.size).toBe(0); + expect(mockQuery).not.toHaveBeenCalled(); + }); + + test("returns empty Set when input contains only empty or whitespace strings", async () => { + const result = await store.getExistingUsernames(["", " "]); + expect(result.size).toBe(0); + expect(mockQuery).not.toHaveBeenCalled(); + }); + + test("normalizes, deduplicates, and queries database with ANY($1::text[])", async () => { + mockQuery.mockResolvedValueOnce({ + rows: [{ username: "alice" }, { username: "bob" }], + rowCount: 2, + }); + + const result = await store.getExistingUsernames([" Alice ", "ALICE", "bob", "Charlie"]); + + expect(mockQuery).toHaveBeenCalledTimes(1); + const [query, params] = mockQuery.mock.calls[0]; + expect(query).toContain( + "SELECT LOWER(username) AS username FROM github_users WHERE LOWER(username) = ANY($1::text[])", + ); + expect(params).toEqual([["alice", "bob", "charlie"]]); + + expect(result).toBeInstanceOf(Set); + expect(result.has("alice")).toBe(true); + expect(result.has("bob")).toBe(true); + expect(result.has("charlie")).toBe(false); + }); +}); diff --git a/src/lib/github/github-client.ts b/src/lib/github/github-client.ts index 05e53b8..3e8b440 100644 --- a/src/lib/github/github-client.ts +++ b/src/lib/github/github-client.ts @@ -839,6 +839,9 @@ export async function getUserData( // Delete stale cache entry (leaderboard calculation path) try { await cacheStoreSingleton.del(cacheKey); + await cacheStoreSingleton.del( + `${cacheConfigSingleton.namespace}:profile:${normalizedUsername.toLowerCase()}`, + ); } catch { // Non-fatal } diff --git a/src/lib/i18n/provider-hook.ts b/src/lib/i18n/provider-hook.ts index 53dc6fe..0c1f793 100644 --- a/src/lib/i18n/provider-hook.ts +++ b/src/lib/i18n/provider-hook.ts @@ -3,14 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import arMessages from "@/locales/ar.json"; import enMessages from "@/locales/en.json"; -import { - DEFAULT_LOCALE, - LOCALE_COOKIE, - isSupportedLocale, - localeMeta, - supportedLocales, - type Locale, -} from "./core"; +import { DEFAULT_LOCALE, LOCALE_COOKIE, localeMeta, supportedLocales, type Locale } from "./core"; type Messages = Record; @@ -32,17 +25,8 @@ function persistLocale(locale: Locale) { } export function useI18nProvider(initialLocale: Locale = DEFAULT_LOCALE) { - const getInitialLocale = () => { - if (typeof window !== "undefined") { - const stored = window.localStorage.getItem(LOCALE_COOKIE); - if (isSupportedLocale(stored)) return stored; - } - return initialLocale; - }; - - const initialLoc = getInitialLocale(); - const [locale, setLocaleState] = useState(initialLoc); - const [messages, setMessages] = useState(() => messagesByLocale[initialLoc]); + const [locale, setLocaleState] = useState(initialLocale); + const [messages, setMessages] = useState(() => messagesByLocale[initialLocale]); const [ready, setReady] = useState(true); const changeLocale = useCallback((next: Locale) => { diff --git a/src/middleware.ts b/src/middleware.ts index d4cbd14..1d1664b 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -9,7 +9,7 @@ import { } from "@/lib/i18n/core"; export function middleware(request: NextRequest) { - const response = NextResponse.next(); + const requestHeaders = new Headers(request.headers); const cookieLocale = request.cookies.get(LOCALE_COOKIE)?.value; if (!isSupportedLocale(cookieLocale)) { @@ -18,11 +18,29 @@ export function middleware(request: NextRequest) { supportedLocales, DEFAULT_LOCALE, ); + requestHeaders.set("x-locale", locale); - response.cookies.set(LOCALE_COOKIE, locale, { path: "/" }); + const response = NextResponse.next({ + request: { + headers: requestHeaders, + }, + }); + + response.cookies.set(LOCALE_COOKIE, locale, { + path: "/", + maxAge: 60 * 60 * 24 * 365, + sameSite: "lax", + }); + + return response; } - return response; + requestHeaders.set("x-locale", cookieLocale); + return NextResponse.next({ + request: { + headers: requestHeaders, + }, + }); } // Run only on routes that produce HTML or read the cookie. Skip Next.js