Skip to content

Commit 487f40e

Browse files
committed
feat: separate the leaderboard logic to two endpoints one for calculate and another for the result
1 parent 668c97a commit 487f40e

7 files changed

Lines changed: 275 additions & 137 deletions

File tree

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { NextResponse } from "next/server";
2+
import yaml from "js-yaml";
3+
import { fetchGitHubUserData } from "@/lib/github";
4+
import { calculateUserScore } from "@/lib/score";
5+
import { createCacheStore, getCacheConfigFromEnv } from "@/lib/cache-store";
6+
7+
export const runtime = "nodejs";
8+
9+
// ─── Types ─────────────────────────────────────────────────────────────
10+
11+
type CommitterEntry = {
12+
rank: number;
13+
name: string;
14+
login: string;
15+
avatarUrl: string;
16+
contributions: number;
17+
};
18+
19+
type CommitterYaml = {
20+
title?: string;
21+
total_user_count?: number;
22+
users?: CommitterEntry[];
23+
};
24+
25+
type ScoredEntry = {
26+
username: string;
27+
name: string | null;
28+
avatarUrl: string;
29+
repoScore: number;
30+
prScore: number;
31+
contributionScore: number;
32+
finalScore: number;
33+
originalRank: number;
34+
originalContributions: number;
35+
impactRank: number;
36+
};
37+
38+
type LeaderboardResult = {
39+
title: string;
40+
totalFromSource: number;
41+
scored: ScoredEntry[];
42+
errors: string[];
43+
};
44+
45+
// ─── Helpers ────────────────────────────────────────────────────────────
46+
47+
function buildLeaderboardCacheKey(country: string, namespace: string): string {
48+
return `${namespace}:leaderboard:${country.trim().toLowerCase()}`;
49+
}
50+
51+
// ─── POST handler ──────────────────────────────────────────────────────
52+
53+
export async function POST(request: Request) {
54+
const { searchParams } = new URL(request.url);
55+
const country = searchParams.get("country")?.trim();
56+
57+
if (!country) {
58+
return NextResponse.json(
59+
{ success: false, error: "Provide a country parameter" },
60+
{ status: 400 },
61+
);
62+
}
63+
64+
// ── 1. Fetch committers from committers.top ──────────────────────────
65+
let committersData: {
66+
title: string;
67+
totalFromSource: number;
68+
users: CommitterEntry[];
69+
};
70+
try {
71+
const url = `https://raw.githubusercontent.com/ashkulz/committers.top/gh-pages/_data/locations/${country}.yml`;
72+
const res = await fetch(url, {
73+
headers: { "User-Agent": "DevImpact-Bot" },
74+
});
75+
if (!res.ok) {
76+
return NextResponse.json(
77+
{
78+
success: false,
79+
error: `Failed to fetch committers data for "${country}"`,
80+
},
81+
{ status: 502 },
82+
);
83+
}
84+
const text = await res.text();
85+
const data = yaml.load(text) as CommitterYaml;
86+
if (!data?.users) {
87+
return NextResponse.json(
88+
{
89+
success: false,
90+
error: `Invalid or empty committers data for "${country}"`,
91+
},
92+
{ status: 502 },
93+
);
94+
}
95+
committersData = {
96+
title: data.title || country,
97+
totalFromSource: data.total_user_count ?? data.users.length,
98+
users: data.users,
99+
};
100+
} catch (err) {
101+
return NextResponse.json(
102+
{
103+
success: false,
104+
error:
105+
err instanceof Error
106+
? err.message
107+
: "Failed to fetch committers data",
108+
},
109+
{ status: 502 },
110+
);
111+
}
112+
113+
// ── 2. Calculate scores for all users ────────────────────────────────
114+
const scored: ScoredEntry[] = [];
115+
const errors: string[] = [];
116+
117+
// Build rank lookup from original committers data
118+
const rankMap = new Map(
119+
committersData.users.map((u) => [
120+
u.login,
121+
{ rank: u.rank, contributions: u.contributions },
122+
]),
123+
);
124+
125+
for (const user of committersData.users) {
126+
try {
127+
const data = await fetchGitHubUserData(user.login);
128+
const score = calculateUserScore(data, user.login);
129+
const original = rankMap.get(user.login) ?? { rank: 0, contributions: 0 };
130+
scored.push({
131+
username: user.login,
132+
name: data.name,
133+
avatarUrl: data.avatarUrl,
134+
repoScore: Math.round(score.repoScore),
135+
prScore: Math.round(score.prScore),
136+
contributionScore: Math.round(score.contributionScore),
137+
finalScore: Math.round(score.finalScore),
138+
originalRank: original.rank,
139+
originalContributions: original.contributions,
140+
impactRank: 0,
141+
});
142+
} catch {
143+
errors.push(user.login);
144+
}
145+
}
146+
147+
// Sort by finalScore descending and assign impactRank
148+
scored.sort((a, b) => b.finalScore - a.finalScore);
149+
scored.forEach((u, idx) => (u.impactRank = idx + 1));
150+
151+
const result: LeaderboardResult = {
152+
title: committersData.title,
153+
totalFromSource: committersData.totalFromSource,
154+
scored,
155+
errors,
156+
};
157+
158+
// ── 3. Store in Redis cache ──────────────────────────────────────────
159+
try {
160+
const cacheConfig = getCacheConfigFromEnv();
161+
const cacheStore = createCacheStore(cacheConfig);
162+
if (cacheStore.enabled) {
163+
const cacheKey = buildLeaderboardCacheKey(country, cacheConfig.namespace);
164+
await cacheStore.set(cacheKey, result, cacheConfig.ttlSeconds);
165+
}
166+
} catch {
167+
// Non-fatal: cache write failure should not block the response
168+
console.warn("Failed to cache leaderboard result");
169+
}
170+
171+
return NextResponse.json({ success: true, ...result });
172+
}

app/api/leaderboard/route.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { NextResponse } from "next/server";
2+
import {
3+
createCacheStore,
4+
getCacheConfigFromEnv,
5+
} from "@/lib/cache-store";
6+
7+
export const runtime = "nodejs";
8+
9+
// ─── Types ─────────────────────────────────────────────────────────────
10+
11+
type ScoredEntry = {
12+
username: string;
13+
name: string | null;
14+
avatarUrl: string;
15+
repoScore: number;
16+
prScore: number;
17+
contributionScore: number;
18+
finalScore: number;
19+
originalRank: number;
20+
originalContributions: number;
21+
impactRank: number;
22+
};
23+
24+
type LeaderboardResult = {
25+
title: string;
26+
totalFromSource: number;
27+
scored: ScoredEntry[];
28+
errors: string[];
29+
};
30+
31+
// ─── Helpers ────────────────────────────────────────────────────────────
32+
33+
function buildLeaderboardCacheKey(country: string, namespace: string): string {
34+
return `${namespace}:leaderboard:${country.trim().toLowerCase()}`;
35+
}
36+
37+
// ─── GET handler ───────────────────────────────────────────────────────
38+
39+
export async function GET(request: Request) {
40+
const { searchParams } = new URL(request.url);
41+
const country = searchParams.get("country")?.trim();
42+
43+
if (!country) {
44+
return NextResponse.json(
45+
{ success: false, error: "Provide a country parameter" },
46+
{ status: 400 },
47+
);
48+
}
49+
50+
// Try to fetch from cache
51+
try {
52+
const cacheConfig = getCacheConfigFromEnv();
53+
const cacheStore = createCacheStore(cacheConfig);
54+
55+
if (cacheStore.enabled) {
56+
const cacheKey = buildLeaderboardCacheKey(country, cacheConfig.namespace);
57+
const cached = await cacheStore.get<LeaderboardResult>(cacheKey);
58+
59+
if (cached) {
60+
return NextResponse.json({ success: true, ...cached });
61+
}
62+
}
63+
} catch {
64+
// Non-fatal: cache read failure should not block the response
65+
console.warn("Failed to read leaderboard from cache");
66+
}
67+
68+
// No cached data found — return empty result
69+
return NextResponse.json({
70+
success: true,
71+
title: country,
72+
totalFromSource: 0,
73+
scored: [],
74+
errors: [],
75+
});
76+
}

app/api/score/route.ts

Lines changed: 0 additions & 56 deletions
This file was deleted.

0 commit comments

Comments
 (0)