Skip to content
Merged
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
83 changes: 32 additions & 51 deletions src/features/comparison/services/compare-service.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
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,
isSupportedLocale,
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 {
Expand Down Expand Up @@ -257,53 +255,36 @@ export async function compareUsers(
usernames: string[],
selectedLanguages: string[],
): Promise<ComparedUserResult[]> {
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;
}
1 change: 1 addition & 0 deletions src/features/developer/services/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./user-service";
export * from "./user-persistence";
export * from "./profile-cache";
74 changes: 74 additions & 0 deletions src/features/developer/services/profile-cache.ts
Original file line number Diff line number Diff line change
@@ -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<UserProfileResponse | null> {
const store = getStore();
if (!store.enabled) {
return null;
}
try {
const key = buildProfileCacheKey(username, selectedLanguages);
const cached = await store.get<UserProfileResponse>(key);
return cached ?? null;
} catch {
return null;
}
}

export async function setCachedProfile(
username: string,
profile: UserProfileResponse,
selectedLanguages: string[] = [],
ttlSeconds?: number,
): Promise<void> {
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<void> {
const store = getStore();
if (!store.enabled || !store.del) {
return;
}
try {
const key = buildProfileCacheKey(username, []);
await store.del(key);
} catch {
// Non-fatal
}
}
76 changes: 71 additions & 5 deletions src/features/developer/services/user-service.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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, {
Expand Down Expand Up @@ -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;
}
Loading
Loading