Skip to content

Commit 8067e4f

Browse files
O2saCopilot
andcommitted
feat: add location detection for GitHub users and initialize PostgreSQL schema
- Enhanced GitHub user data model to include `login` and `location` fields. - Implemented a location detector to normalize user-provided locations into country slugs. - Updated GraphQL queries to fetch the new `login` and `location` fields. - Increased default pull request count from 250 to 300. - Added a script to initialize the PostgreSQL database schema. - Updated package dependencies, including adding `pg` for PostgreSQL support. - Refactored error handling and improved code formatting for better readability. Co-authored-by: Copilot <copilot@github.com>
1 parent 487f40e commit 8067e4f

14 files changed

Lines changed: 1186 additions & 204 deletions

File tree

app/api/calculate-leaderboard/route.ts

Lines changed: 133 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import yaml from "js-yaml";
33
import { fetchGitHubUserData } from "@/lib/github";
44
import { calculateUserScore } from "@/lib/score";
55
import { createCacheStore, getCacheConfigFromEnv } from "@/lib/cache-store";
6+
import { getDatabaseStore } from "@/lib/db-store";
7+
import { detectCountry } from "@/lib/location-detector";
68

79
export const runtime = "nodejs";
810

@@ -48,6 +50,25 @@ function buildLeaderboardCacheKey(country: string, namespace: string): string {
4850
return `${namespace}:leaderboard:${country.trim().toLowerCase()}`;
4951
}
5052

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+
5172
// ─── POST handler ──────────────────────────────────────────────────────
5273

5374
export async function POST(request: Request) {
@@ -61,6 +82,10 @@ export async function POST(request: Request) {
6182
);
6283
}
6384

85+
// Ensure database schema exists
86+
const db = getDatabaseStore();
87+
await db.initializeSchema();
88+
6489
// ── 1. Fetch committers from committers.top ──────────────────────────
6590
let committersData: {
6691
title: string;
@@ -110,63 +135,144 @@ export async function POST(request: Request) {
110135
);
111136
}
112137

113-
// ── 2. Calculate scores for all users ────────────────────────────────
114-
const scored: ScoredEntry[] = [];
138+
// ── 2a. Seed NEW users from committers.top ───────────────────────────
115139
const errors: string[] = [];
140+
const seedLimit = getSeedLimit();
141+
const usersToSeed = committersData.users.slice(0, seedLimit);
142+
let newUsersCount = 0;
143+
let skippedExistingCount = 0;
116144

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) {
145+
for (const user of usersToSeed) {
126146
try {
147+
const exists = await db.userExists(user.login);
148+
if (exists) {
149+
skippedExistingCount += 1;
150+
continue;
151+
}
152+
127153
const data = await fetchGitHubUserData(user.login);
128154
const score = calculateUserScore(data, user.login);
129-
const original = rankMap.get(user.login) ?? { rank: 0, contributions: 0 };
130-
scored.push({
131-
username: user.login,
155+
const countryDetected = detectCountry(data.location);
156+
157+
await db.upsertUser({
158+
username: data.login,
132159
name: data.name,
133160
avatarUrl: data.avatarUrl,
161+
location: data.location,
162+
country: countryDetected,
163+
rawData: data,
164+
scores: score,
134165
repoScore: Math.round(score.repoScore),
135166
prScore: Math.round(score.prScore),
136167
contributionScore: Math.round(score.contributionScore),
137168
finalScore: Math.round(score.finalScore),
138-
originalRank: original.rank,
139-
originalContributions: original.contributions,
140-
impactRank: 0,
169+
staleDays: getStaleDays(),
141170
});
171+
172+
newUsersCount += 1;
142173
} catch {
143174
errors.push(user.login);
144175
}
145176
}
146177

147-
// Sort by finalScore descending and assign impactRank
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
148244
scored.sort((a, b) => b.finalScore - a.finalScore);
149245
scored.forEach((u, idx) => (u.impactRank = idx + 1));
150246

151247
const result: LeaderboardResult = {
152248
title: committersData.title,
153-
totalFromSource: committersData.totalFromSource,
249+
totalFromSource: totalCount,
154250
scored,
155-
errors,
251+
errors: [...new Set(errors)], // deduplicate
156252
};
157253

158-
// ── 3. Store in Redis cache ──────────────────────────────────────────
254+
// ── 4. Invalidate Redis cache (lazy rebuild on next GET) ─────────────
159255
try {
160256
const cacheConfig = getCacheConfigFromEnv();
161257
const cacheStore = createCacheStore(cacheConfig);
162-
if (cacheStore.enabled) {
258+
if (cacheStore.enabled && cacheStore.del) {
163259
const cacheKey = buildLeaderboardCacheKey(country, cacheConfig.namespace);
164-
await cacheStore.set(cacheKey, result, cacheConfig.ttlSeconds);
260+
await cacheStore.del(cacheKey);
165261
}
166262
} catch {
167-
// Non-fatal: cache write failure should not block the response
168-
console.warn("Failed to cache leaderboard result");
263+
// Non-fatal: cache invalidation failure should not block the response
264+
console.warn("Failed to invalidate leaderboard cache");
169265
}
170266

171-
return NextResponse.json({ success: true, ...result });
172-
}
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+
});
278+
}

app/api/compare/route.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ import { fetchGitHubUserData } from "../../../lib/github";
33
import { calculateUserScore } from "../../../lib/score";
44
import { normalizeSelectedLanguages } from "@/lib/scoring/languageScoring";
55
import { toSafeApiError } from "@/lib/github-graphql-client";
6+
import { getDatabaseStore } from "@/lib/db-store";
7+
import {
8+
createCacheStore,
9+
getCacheConfigFromEnv,
10+
} from "@/lib/cache-store";
11+
import { detectCountry } from "@/lib/location-detector";
612
import type { CompareInsights, SafeApiError } from "@/types/api-response";
713
import {
814
DEFAULT_LOCALE,
@@ -322,7 +328,7 @@ async function compareUsers(
322328
);
323329

324330
results.push({
325-
username,
331+
username: data.login,
326332
name: data.name,
327333
avatarUrl: data.avatarUrl,
328334
repoScore: Math.round(score.repoScore),
@@ -340,6 +346,43 @@ async function compareUsers(
340346
signals: score.signals,
341347
explanations: score.explanations,
342348
});
349+
350+
// ── Fire-and-forget: detect country & upsert into DB ──────────────
351+
const country = detectCountry(data.location);
352+
if (country) {
353+
const staleDays = parseInt(
354+
process.env.GITHUB_USER_STALE_DAYS ?? "14",
355+
10,
356+
);
357+
358+
const db = getDatabaseStore();
359+
db.upsertUser({
360+
username: data.login,
361+
name: data.name,
362+
avatarUrl: data.avatarUrl,
363+
location: data.location,
364+
country,
365+
rawData: data,
366+
scores: score,
367+
repoScore: Math.round(score.repoScore),
368+
prScore: Math.round(score.prScore),
369+
contributionScore: Math.round(score.contributionScore),
370+
finalScore: Math.round(score.finalScore),
371+
staleDays,
372+
})
373+
.then(() => {
374+
// Invalidate Redis cache for this country
375+
const cacheConfig = getCacheConfigFromEnv();
376+
const cacheStore = createCacheStore(cacheConfig);
377+
if (cacheStore.enabled && cacheStore.del) {
378+
const key = `${cacheConfig.namespace}:leaderboard:${country}`;
379+
cacheStore.del(key).catch(() => {});
380+
}
381+
})
382+
.catch((err: unknown) => {
383+
console.warn("Failed to upsert user from compare:", err);
384+
});
385+
}
343386
}
344387

345388
return results;

0 commit comments

Comments
 (0)