11import { 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- import { getDatabaseStore } from "@/lib/db-store" ;
7- import { detectCountry } from "@/lib/location-detector" ;
2+ import { calculateLeaderboard } from "@/lib/calculate-leaderboard" ;
83
94export const runtime = "nodejs" ;
105
11- // ─── Types ─────────────────────────────────────────────────────────────
12-
13- type CommitterEntry = {
14- rank : number ;
15- name : string ;
16- login : string ;
17- avatarUrl : string ;
18- contributions : number ;
19- } ;
20-
21- type CommitterYaml = {
22- title ?: string ;
23- total_user_count ?: number ;
24- users ?: CommitterEntry [ ] ;
25- } ;
26-
27- type ScoredEntry = {
28- username : string ;
29- name : string | null ;
30- avatarUrl : string ;
31- repoScore : number ;
32- prScore : number ;
33- contributionScore : number ;
34- finalScore : number ;
35- originalRank : number ;
36- originalContributions : number ;
37- impactRank : number ;
38- } ;
39-
40- type LeaderboardResult = {
41- title : string ;
42- totalFromSource : number ;
43- scored : ScoredEntry [ ] ;
44- errors : string [ ] ;
45- } ;
46-
47- // ─── Helpers ────────────────────────────────────────────────────────────
48-
49- function buildLeaderboardCacheKey ( country : string , namespace : string ) : string {
50- return `${ namespace } :leaderboard:${ country . trim ( ) . toLowerCase ( ) } ` ;
51- }
52-
53- function getEnvInt ( key : string , fallback : number ) : number {
54- const raw = process . env [ key ] ?. trim ( ) ;
55- if ( ! raw ) return fallback ;
56- const parsed = Number . parseInt ( raw , 10 ) ;
57- return Number . isFinite ( parsed ) && parsed > 0 ? parsed : fallback ;
58- }
59-
60- function getStaleDays ( ) : number {
61- return getEnvInt ( "LEADERBOARD_USER_STALE_DAYS" , 30 ) ;
62- }
63-
64- function getSeedLimit ( ) : number {
65- return getEnvInt ( "LEADERBOARD_SEED_LIMIT" , 256 ) ;
66- }
67-
68- function getRefreshLimit ( ) : number {
69- return getEnvInt ( "LEADERBOARD_REFRESH_LIMIT" , 500 ) ;
70- }
71-
72- // ─── POST handler ──────────────────────────────────────────────────────
73-
746export async function POST ( request : Request ) {
757 const { searchParams } = new URL ( request . url ) ;
768 const country = searchParams . get ( "country" ) ?. trim ( ) ;
@@ -82,197 +14,16 @@ export async function POST(request: Request) {
8214 ) ;
8315 }
8416
85- // Ensure database schema exists
86- const db = getDatabaseStore ( ) ;
87- await db . initializeSchema ( ) ;
88-
89- // ── 1. Fetch committers from committers.top ──────────────────────────
90- let committersData : {
91- title : string ;
92- totalFromSource : number ;
93- users : CommitterEntry [ ] ;
94- } ;
9517 try {
96- const url = `https://raw.githubusercontent.com/ashkulz/committers.top/gh-pages/_data/locations/${ country } .yml` ;
97- const res = await fetch ( url , {
98- headers : { "User-Agent" : "DevImpact-Bot" } ,
99- } ) ;
100- if ( ! res . ok ) {
101- return NextResponse . json (
102- {
103- success : false ,
104- error : `Failed to fetch committers data for "${ country } "` ,
105- } ,
106- { status : 502 } ,
107- ) ;
108- }
109- const text = await res . text ( ) ;
110- const data = yaml . load ( text ) as CommitterYaml ;
111- if ( ! data ?. users ) {
112- return NextResponse . json (
113- {
114- success : false ,
115- error : `Invalid or empty committers data for "${ country } "` ,
116- } ,
117- { status : 502 } ,
118- ) ;
119- }
120- committersData = {
121- title : data . title || country ,
122- totalFromSource : data . total_user_count ?? data . users . length ,
123- users : data . users ,
124- } ;
18+ const result = await calculateLeaderboard ( country ) ;
19+ return NextResponse . json ( { success : true , ...result } ) ;
12520 } catch ( err ) {
12621 return NextResponse . json (
12722 {
12823 success : false ,
129- error :
130- err instanceof Error
131- ? err . message
132- : "Failed to fetch committers data" ,
24+ error : err instanceof Error ? err . message : "Failed to calculate leaderboard" ,
13325 } ,
13426 { status : 502 } ,
13527 ) ;
13628 }
137-
138- // ── 2a. Seed NEW users from committers.top ───────────────────────────
139- const errors : string [ ] = [ ] ;
140- const seedLimit = getSeedLimit ( ) ;
141- const usersToSeed = committersData . users . slice ( 0 , seedLimit ) ;
142- let newUsersCount = 0 ;
143- let skippedExistingCount = 0 ;
144-
145- for ( const user of usersToSeed ) {
146- try {
147- const exists = await db . userExists ( user . login ) ;
148- if ( exists ) {
149- skippedExistingCount += 1 ;
150- continue ;
151- }
152-
153- const data = await fetchGitHubUserData ( user . login ) ;
154- const score = calculateUserScore ( data , user . login ) ;
155- const countryDetected = detectCountry ( data . location ) ;
156-
157- await db . upsertUser ( {
158- username : data . login ,
159- name : data . name ,
160- avatarUrl : data . avatarUrl ,
161- location : data . location ,
162- country : countryDetected ,
163- rawData : data ,
164- scores : score ,
165- repoScore : Math . round ( score . repoScore ) ,
166- prScore : Math . round ( score . prScore ) ,
167- contributionScore : Math . round ( score . contributionScore ) ,
168- finalScore : Math . round ( score . finalScore ) ,
169- staleDays : getStaleDays ( ) ,
170- } ) ;
171-
172- newUsersCount += 1 ;
173- } catch {
174- errors . push ( user . login ) ;
175- }
176- }
177-
178- // ── 2b. Refresh STALE users from DB top N ───────────────────────────
179- const refreshLimit = getRefreshLimit ( ) ;
180- const topUsers = await db . getTopUsers ( country , refreshLimit ) ;
181- let refreshedCount = 0 ;
182-
183- for ( const row of topUsers ) {
184- // Check if stale
185- if ( row . stale_after >= new Date ( ) ) {
186- continue ; // still fresh
187- }
188-
189- try {
190- const data = await fetchGitHubUserData ( row . username ) ;
191- const score = calculateUserScore ( data , row . username ) ;
192- const countryDetected = detectCountry ( data . location ) ;
193-
194- await db . upsertUser ( {
195- username : data . login ,
196- name : data . name ,
197- avatarUrl : data . avatarUrl ,
198- location : data . location ,
199- country : countryDetected ,
200- rawData : data ,
201- scores : score ,
202- repoScore : Math . round ( score . repoScore ) ,
203- prScore : Math . round ( score . prScore ) ,
204- contributionScore : Math . round ( score . contributionScore ) ,
205- finalScore : Math . round ( score . finalScore ) ,
206- staleDays : getStaleDays ( ) ,
207- } ) ;
208-
209- refreshedCount += 1 ;
210- } catch {
211- errors . push ( row . username ) ;
212- }
213- }
214-
215- // ── 3. Build & cache the leaderboard result ──────────────────────────
216- const allUsers = await db . getLeaderboard ( country , getEnvInt ( "LEADERBOARD_DISPLAY_LIMIT" , 500 ) ) ;
217- const totalCount = await db . getLeaderboardCount ( country ) ;
218-
219- // Build rank lookup from original committers data
220- const rankMap = new Map (
221- committersData . users . map ( ( u ) => [
222- u . login ,
223- { rank : u . rank , contributions : u . contributions } ,
224- ] ) ,
225- ) ;
226-
227- const scored : ScoredEntry [ ] = allUsers . map ( ( row ) => {
228- const original = rankMap . get ( row . username ) ?? { rank : 0 , contributions : 0 } ;
229- return {
230- username : row . username ,
231- name : row . name ,
232- avatarUrl : row . avatar_url ,
233- repoScore : row . repo_score ,
234- prScore : row . pr_score ,
235- contributionScore : row . contribution_score ,
236- finalScore : row . final_score ,
237- originalRank : original . rank ,
238- originalContributions : original . contributions ,
239- impactRank : 0 ,
240- } ;
241- } ) ;
242-
243- // Sort and assign impactRank
244- scored . sort ( ( a , b ) => b . finalScore - a . finalScore ) ;
245- scored . forEach ( ( u , idx ) => ( u . impactRank = idx + 1 ) ) ;
246-
247- const result : LeaderboardResult = {
248- title : committersData . title ,
249- totalFromSource : totalCount ,
250- scored,
251- errors : [ ...new Set ( errors ) ] , // deduplicate
252- } ;
253-
254- // ── 4. Invalidate Redis cache (lazy rebuild on next GET) ─────────────
255- try {
256- const cacheConfig = getCacheConfigFromEnv ( ) ;
257- const cacheStore = createCacheStore ( cacheConfig ) ;
258- if ( cacheStore . enabled && cacheStore . del ) {
259- const cacheKey = buildLeaderboardCacheKey ( country , cacheConfig . namespace ) ;
260- await cacheStore . del ( cacheKey ) ;
261- }
262- } catch {
263- // Non-fatal: cache invalidation failure should not block the response
264- console . warn ( "Failed to invalidate leaderboard cache" ) ;
265- }
266-
267- return NextResponse . json ( {
268- success : true ,
269- ...result ,
270- _meta : {
271- newUsers : newUsersCount ,
272- refreshedUsers : refreshedCount ,
273- skippedExisting : skippedExistingCount ,
274- errors : errors . length ,
275- totalInDb : totalCount ,
276- } ,
277- } ) ;
27829}
0 commit comments