RYP / src /lib /authClient.ts
Soumya79's picture
Upload 1361 files
f91a684 verified
import { getActivityDateKey } from './activityDates';
export const AUTH_TOKEN_KEY = 'ryp_auth_token';
const LOCAL_AUTH_TOKEN_PREFIX = 'local-demo:';
const LOCAL_AUTH_USERS_KEY = 'ryp_local_users';
export type AuthUser = {
id: string;
email: string;
displayName: string;
photoURL?: string;
createdAt: string;
currentStreak: number;
longestStreak: number;
coins: number;
};
export type CoinTransaction = {
id: string;
userId: string;
amount: number;
source: string;
category: string;
createdAt: string;
};
export type ProgressLeaderboardEntry = {
id: string;
displayName: string;
solvedCount: number;
currentStreak: number;
weeklySolved: number;
coins: number;
score: number;
rank: number;
isCurrentUser: boolean;
codingStreak?: number;
languageStats?: Record<string, number>;
};
export type UserProgressStats = {
solvedQuestionIds: string[];
dailyActivity: Record<string, number>;
dailyActivityBreakdown?: Record<string, Record<string, number>>;
solvedCount: number;
activeDays: number;
weeklySolved: number;
leaderboard: ProgressLeaderboardEntry[];
codingLeaderboard: ProgressLeaderboardEntry[];
codingSolvedCount: number;
};
type StoredLocalUser = AuthUser & {
password: string;
lastActiveAt?: string;
streakTimeZone?: string;
solvedQuestionIds?: string[];
dailyActivity?: Record<string, number>;
dailyActivityBreakdown?: Record<string, Record<string, number>>;
languageStats?: Record<string, number>;
purchasedItemIds?: number[];
};
function apiUrl(path: string): string {
const base = (
import.meta.env.VITE_API_BASE as string | undefined
)
?.trim()
.replace(/\/$/, '') ?? '';
const p = path.startsWith('/') ? path : `/${path}`;
return `${base}${p}`;
}
function clientTimeZone(): string | null {
try {
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone?.trim();
return timeZone || null;
} catch {
return null;
}
}
function isLocalDevOrigin() {
if (typeof window === 'undefined') {
return false;
}
const host = window.location.hostname;
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
}
function shouldUseLocalAuthFallback(error: unknown) {
return (
isLocalDevOrigin() &&
error instanceof Error &&
error.message.startsWith('Cannot reach server')
);
}
function loadLocalUsers(): StoredLocalUser[] {
if (typeof window === 'undefined') {
return [];
}
const raw = window.localStorage.getItem(LOCAL_AUTH_USERS_KEY);
if (!raw) {
return [];
}
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function saveLocalUsers(users: StoredLocalUser[]) {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(LOCAL_AUTH_USERS_KEY, JSON.stringify(users));
}
function normalizeEmail(email: string) {
return email.trim().toLowerCase();
}
function createLocalUserId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `local-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
function createLocalToken(userId: string) {
return `${LOCAL_AUTH_TOKEN_PREFIX}${userId}`;
}
function isLocalToken(token: string) {
return token.startsWith(LOCAL_AUTH_TOKEN_PREFIX);
}
function toAuthUser(user: StoredLocalUser): AuthUser {
const normalizedUser = normalizeLocalUser(user);
const { password: _password, lastActiveAt: _lastActiveAt, streakTimeZone: _streakTimeZone, solvedQuestionIds: _solvedQuestionIds, dailyActivity: _dailyActivity, purchasedItemIds: _purchasedItemIds, ...safeUser } = normalizedUser;
return {
...safeUser,
currentStreak: visibleLocalCurrentStreak(normalizedUser),
};
}
function findLocalUserByToken(token: string) {
if (!isLocalToken(token)) {
return null;
}
const userId = token.slice(LOCAL_AUTH_TOKEN_PREFIX.length);
return loadLocalUsers().find((user) => user.id === userId) ?? null;
}
function registerLocalUser(
email: string,
password: string,
displayName: string,
): { token: string; user: AuthUser } {
const users = loadLocalUsers();
const normalizedEmail = normalizeEmail(email);
if (users.some((user) => normalizeEmail(user.email) === normalizedEmail)) {
throw new Error('An account with this email already exists.');
}
const newUser: StoredLocalUser = {
id: createLocalUserId(),
email: normalizedEmail,
password,
displayName: displayName.trim(),
createdAt: new Date().toISOString(),
lastActiveAt: new Date().toISOString(),
currentStreak: 1,
longestStreak: 1,
coins: 0,
streakTimeZone: clientTimeZone() ?? 'UTC',
solvedQuestionIds: [],
dailyActivity: {},
purchasedItemIds: [],
};
users.push(newUser);
saveLocalUsers(users);
return {
token: createLocalToken(newUser.id),
user: toAuthUser(newUser),
};
}
function loginLocalUser(email: string, password: string): { token: string; user: AuthUser } {
const normalizedEmail = normalizeEmail(email);
const users = loadLocalUsers();
const userIndex = users.findIndex(
(candidate) =>
normalizeEmail(candidate.email) === normalizedEmail && candidate.password === password,
);
if (userIndex === -1) {
throw new Error('Invalid email or password.');
}
const user = normalizeLocalUser(users[userIndex]);
touchLocalStreak(user, new Date());
users[userIndex] = user;
saveLocalUsers(users);
return {
token: createLocalToken(user.id),
user: toAuthUser(user),
};
}
function updateLocalUser(
token: string,
data: { displayName?: string; oldPassword?: string; newPassword?: string },
): { user: AuthUser } {
const users = loadLocalUsers();
const userId = token.slice(LOCAL_AUTH_TOKEN_PREFIX.length);
const userIndex = users.findIndex((candidate) => candidate.id === userId);
if (userIndex === -1) {
throw new Error('Not authenticated');
}
const user = users[userIndex];
if (data.newPassword) {
if (!data.oldPassword || data.oldPassword !== user.password) {
throw new Error('Current password is incorrect.');
}
user.password = data.newPassword;
}
if (data.displayName?.trim()) {
user.displayName = data.displayName.trim();
}
users[userIndex] = user;
saveLocalUsers(users);
return { user: toAuthUser(user) };
}
function updateLocalCoins(token: string, amount: number): { user: AuthUser } {
const users = loadLocalUsers();
const userId = token.slice(LOCAL_AUTH_TOKEN_PREFIX.length);
const userIndex = users.findIndex((candidate) => candidate.id === userId);
if (userIndex === -1) {
throw new Error('Not authenticated');
}
const user = users[userIndex];
user.coins = Math.max(0, (user.coins || 0) + amount);
users[userIndex] = user;
saveLocalUsers(users);
return { user: toAuthUser(user) };
}
function normalizeLocalSolvedQuestionIds(ids: string[] | undefined) {
const seen = new Set<string>();
const normalized: string[] = [];
for (const rawId of ids ?? []) {
const id = rawId.trim();
if (!id || seen.has(id)) {
continue;
}
seen.add(id);
normalized.push(id);
}
return normalized;
}
function normalizeLocalDailyActivity(activity: Record<string, number> | undefined) {
const normalized: Record<string, number> = {};
for (const [key, count] of Object.entries(activity ?? {})) {
if (typeof count !== 'number' || !Number.isFinite(count) || count <= 0) {
continue;
}
normalized[key.trim()] = Math.floor(count);
}
return normalized;
}
function normalizeLocalUser(user: StoredLocalUser): StoredLocalUser {
const solvedQuestionIds = normalizeLocalSolvedQuestionIds(user.solvedQuestionIds);
const dailyActivity = normalizeLocalDailyActivity(user.dailyActivity);
const currentStreak = Math.max(0, Math.floor(user.currentStreak || 0));
const longestStreak = Math.max(currentStreak, Math.floor(user.longestStreak || 0));
const coins = Math.max(0, Math.floor(user.coins || 0));
return {
...user,
currentStreak,
longestStreak,
coins,
streakTimeZone: user.streakTimeZone || clientTimeZone() || 'UTC',
solvedQuestionIds,
dailyActivity,
purchasedItemIds: Array.isArray(user.purchasedItemIds) ? user.purchasedItemIds : [],
};
}
function differenceInLocalDays(later: Date, earlier: Date) {
const laterDate = new Date(later);
const earlierDate = new Date(earlier);
laterDate.setHours(0, 0, 0, 0);
earlierDate.setHours(0, 0, 0, 0);
return Math.round((laterDate.getTime() - earlierDate.getTime()) / (1000 * 60 * 60 * 24));
}
function visibleLocalCurrentStreak(user: StoredLocalUser) {
if (!user.lastActiveAt) {
return 0;
}
const lastActiveAt = new Date(user.lastActiveAt);
if (Number.isNaN(lastActiveAt.getTime())) {
return 0;
}
const dayDelta = differenceInLocalDays(new Date(), lastActiveAt);
return dayDelta <= 1 ? Math.max(0, Math.floor(user.currentStreak || 0)) : 0;
}
function weeklySolvedFromLocalActivity(activity: Record<string, number>) {
const normalizedActivity = normalizeLocalDailyActivity(activity);
let total = 0;
for (let offset = 0; offset < 7; offset += 1) {
const currentDate = new Date();
currentDate.setDate(currentDate.getDate() - offset);
total += normalizedActivity[getActivityDateKey(currentDate)] || 0;
}
return total;
}
function trimLocalLeaderboard(entries: ProgressLeaderboardEntry[], currentUserId: string, limit = 10) {
if (entries.length <= limit) {
return entries;
}
const currentIndex = entries.findIndex((entry) => entry.id === currentUserId);
if (currentIndex === -1 || currentIndex < limit) {
return entries.slice(0, limit);
}
return [...entries.slice(0, limit - 1), entries[currentIndex]];
}
function weeklyCodingSolvedFromLocalActivity(breakdown: Record<string, Record<string, number>> | undefined) {
if (!breakdown) return 0;
let total = 0;
for (let offset = 0; offset < 7; offset += 1) {
const currentDate = new Date();
currentDate.setDate(currentDate.getDate() - offset);
const dayKey = getActivityDateKey(currentDate);
const dayBreakdown = breakdown[dayKey];
if (dayBreakdown && typeof dayBreakdown.coding === 'number') {
total += dayBreakdown.coding;
}
}
return total;
}
function codingCurrentStreakFromLocalActivity(breakdown: Record<string, Record<string, number>> | undefined) {
if (!breakdown || Object.keys(breakdown).length === 0) return 0;
let streak = 0;
const now = new Date();
const todayKey = getActivityDateKey(now);
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayKey = getActivityDateKey(yesterday);
const todayCoding = breakdown[todayKey]?.['coding'] || 0;
const yesterdayCoding = breakdown[yesterdayKey]?.['coding'] || 0;
if (todayCoding === 0 && yesterdayCoding === 0) return 0;
let currentDay = new Date(now);
if (todayCoding === 0) {
currentDay.setDate(currentDay.getDate() - 1);
}
while (true) {
const dayKey = getActivityDateKey(currentDay);
const codingCount = breakdown[dayKey]?.['coding'] || 0;
if (codingCount > 0) {
streak++;
currentDay.setDate(currentDay.getDate() - 1);
} else {
break;
}
}
return streak;
}
function buildLocalCodingLeaderboard(users: StoredLocalUser[], currentUserId: string) {
const leaderboard = users
.map((candidate) => {
const user = normalizeLocalUser(candidate);
let codingSolvedCount = 0;
for (const id of user.solvedQuestionIds || []) {
if (!id.startsWith('sd-') && !id.startsWith('cs-') && !id.startsWith('apt-')) {
codingSolvedCount++;
}
}
const weeklySolved = weeklyCodingSolvedFromLocalActivity(user.dailyActivityBreakdown);
const codingStreak = codingCurrentStreakFromLocalActivity(user.dailyActivityBreakdown);
const currentStreak = visibleLocalCurrentStreak(user);
const coins = Math.max(0, user.coins || 0);
return {
id: user.id,
displayName: user.displayName.trim() || 'User',
solvedCount: codingSolvedCount,
currentStreak,
codingStreak,
weeklySolved,
coins,
score: codingSolvedCount * 100,
rank: 0,
languageStats: user.languageStats,
isCurrentUser: user.id === currentUserId,
} satisfies ProgressLeaderboardEntry;
})
.sort((left, right) => {
if (right.score !== left.score) return right.score - left.score;
if (right.solvedCount !== left.solvedCount) return right.solvedCount - left.solvedCount;
if (right.weeklySolved !== left.weeklySolved) return right.weeklySolved - left.weeklySolved;
if (right.coins !== left.coins) return right.coins - left.coins;
return left.displayName.localeCompare(right.displayName);
})
.map((entry, index) => ({
...entry,
rank: index + 1,
}));
return leaderboard;
}
function buildLocalLeaderboard(users: StoredLocalUser[], currentUserId: string) {
const leaderboard = users
.map((candidate) => {
const user = normalizeLocalUser(candidate);
const solvedCount = user.solvedQuestionIds?.length || 0;
const weeklySolved = weeklySolvedFromLocalActivity(user.dailyActivity || {});
const currentStreak = visibleLocalCurrentStreak(user);
const coins = Math.max(0, user.coins || 0);
return {
id: user.id,
displayName: user.displayName.trim() || 'User',
solvedCount,
currentStreak,
weeklySolved,
coins,
score: solvedCount * 100 + weeklySolved * 15 + currentStreak * 30 + coins,
rank: 0,
isCurrentUser: user.id === currentUserId,
} satisfies ProgressLeaderboardEntry;
})
.sort((left, right) => {
if (right.score !== left.score) {
return right.score - left.score;
}
if (right.solvedCount !== left.solvedCount) {
return right.solvedCount - left.solvedCount;
}
if (right.weeklySolved !== left.weeklySolved) {
return right.weeklySolved - left.weeklySolved;
}
if (right.coins !== left.coins) {
return right.coins - left.coins;
}
return left.displayName.localeCompare(right.displayName);
})
.map((entry, index) => ({
...entry,
rank: index + 1,
}));
// Return full list — the dashboard panel limits to 3 in the UI; the full leaderboard page shows all.
return leaderboard;
}
function buildLocalProgressStats(userId: string, users: StoredLocalUser[]): UserProgressStats {
const user = users.map(normalizeLocalUser).find((candidate) => candidate.id === userId);
if (!user) {
throw new Error('Not authenticated');
}
const solvedQuestionIds = normalizeLocalSolvedQuestionIds(user.solvedQuestionIds);
const dailyActivity = normalizeLocalDailyActivity(user.dailyActivity);
const rawUser = users.find((candidate) => candidate.id === userId);
const dailyActivityBreakdown: Record<string, Record<string, number>> = rawUser?.dailyActivityBreakdown ?? {};
let codingSolvedCount = 0;
for (const id of solvedQuestionIds) {
if (!id.startsWith('sd-') && !id.startsWith('cs-') && !id.startsWith('apt-')) {
codingSolvedCount++;
}
}
return {
solvedQuestionIds,
dailyActivity,
dailyActivityBreakdown,
solvedCount: solvedQuestionIds.length,
activeDays: Object.values(dailyActivity).filter((count) => count > 0).length,
weeklySolved: weeklySolvedFromLocalActivity(dailyActivity),
leaderboard: buildLocalLeaderboard(users, userId),
codingLeaderboard: buildLocalCodingLeaderboard(users, userId),
codingSolvedCount,
};
}
function updateLocalStreakForSolve(user: StoredLocalUser, now: Date) {
const lastActiveAt = user.lastActiveAt ? new Date(user.lastActiveAt) : null;
const lastActiveIsValid = Boolean(lastActiveAt && !Number.isNaN(lastActiveAt.getTime()));
if (!lastActiveIsValid) {
user.currentStreak = 1;
user.longestStreak = Math.max(user.longestStreak || 0, 1);
user.lastActiveAt = now.toISOString();
return;
}
const dayDelta = differenceInLocalDays(now, lastActiveAt!);
if (dayDelta <= 0) {
return;
}
if (dayDelta === 1) {
user.currentStreak = Math.max(0, user.currentStreak || 0) + 1;
} else {
user.currentStreak = 1;
}
user.longestStreak = Math.max(user.longestStreak || 0, user.currentStreak);
user.lastActiveAt = now.toISOString();
}
/**
* Touch the streak on every login event (password login, session restore, Google OAuth).
* Mirrors the backend nextStreakUpdate logic:
* - No lastActiveAt → streak becomes 1.
* - Same calendar day → no change (already counted).
* - Consecutive day → streak increments by 1.
* - Missed day(s) → streak resets to 1.
* Always keeps longestStreak up to date.
*/
function touchLocalStreak(user: StoredLocalUser, now: Date) {
const lastActiveAt = user.lastActiveAt ? new Date(user.lastActiveAt) : null;
const lastActiveIsValid = Boolean(lastActiveAt && !Number.isNaN(lastActiveAt.getTime()));
if (!lastActiveIsValid) {
user.currentStreak = 1;
user.longestStreak = Math.max(user.longestStreak || 0, 1);
user.lastActiveAt = now.toISOString();
return;
}
const dayDelta = differenceInLocalDays(now, lastActiveAt!);
if (dayDelta <= 0) {
// Already counted for today — nothing to do.
return;
}
if (dayDelta === 1) {
user.currentStreak = Math.max(0, user.currentStreak || 0) + 1;
} else {
// Missed one or more days — start a fresh streak.
user.currentStreak = 1;
}
user.longestStreak = Math.max(user.longestStreak || 0, user.currentStreak);
user.lastActiveAt = now.toISOString();
}
function mergeLocalProgress(token: string, data: { solvedQuestionIds: string[]; dailyActivity: Record<string, number> }) {
const users = loadLocalUsers();
const userId = token.slice(LOCAL_AUTH_TOKEN_PREFIX.length);
const userIndex = users.findIndex((candidate) => candidate.id === userId);
if (userIndex === -1) {
throw new Error('Not authenticated');
}
const user = normalizeLocalUser(users[userIndex]);
const mergedSolvedQuestionIds = normalizeLocalSolvedQuestionIds([
...(user.solvedQuestionIds || []),
...normalizeLocalSolvedQuestionIds(data.solvedQuestionIds),
]);
const mergedDailyActivity = {
...normalizeLocalDailyActivity(user.dailyActivity),
...normalizeLocalDailyActivity(data.dailyActivity),
};
user.solvedQuestionIds = mergedSolvedQuestionIds;
user.dailyActivity = mergedDailyActivity;
users[userIndex] = user;
saveLocalUsers(users);
return {
user: toAuthUser(user),
stats: buildLocalProgressStats(user.id, users),
};
}
function recordLocalSolvedQuestion(
token: string,
questionId: string,
difficulty: string,
language?: string,
) {
const users = loadLocalUsers();
const userId = token.slice(LOCAL_AUTH_TOKEN_PREFIX.length);
const userIndex = users.findIndex((candidate) => candidate.id === userId);
if (userIndex === -1) {
throw new Error('Not authenticated');
}
const reward = getSolveReward(difficulty);
if (reward == null) {
throw new Error('Question difficulty is required.');
}
const user = normalizeLocalUser(users[userIndex]);
const solvedQuestionIds = normalizeLocalSolvedQuestionIds(user.solvedQuestionIds);
const dailyActivity = normalizeLocalDailyActivity(user.dailyActivity);
const alreadySolved = solvedQuestionIds.includes(questionId);
if (!alreadySolved) {
const now = new Date();
solvedQuestionIds.push(questionId);
const todayKey = getActivityDateKey(now);
dailyActivity[todayKey] = (dailyActivity[todayKey] || 0) + 1;
// Detect section from question ID prefix (mirrors backend logic)
let section = 'coding';
if (questionId.startsWith('sd-')) {
section = 'systemDesign';
} else if (questionId.startsWith('cs-dbms-')) {
section = 'dbms';
} else if (questionId.startsWith('cs-os-')) {
section = 'os';
} else if (questionId.startsWith('cs-cn-')) {
section = 'cn';
} else if (questionId.startsWith('cs-')) {
section = 'csFundamentals';
} else if (questionId.startsWith('apt-')) {
section = 'aptitude';
}
const breakdown = user.dailyActivityBreakdown ?? {};
if (!breakdown[todayKey]) {
breakdown[todayKey] = {};
}
breakdown[todayKey][section] = (breakdown[todayKey][section] || 0) + 1;
user.dailyActivityBreakdown = breakdown;
user.solvedQuestionIds = solvedQuestionIds;
user.dailyActivity = dailyActivity;
if (section === 'coding' && language) {
const stats = user.languageStats ?? {};
const lang = language.toLowerCase().trim();
stats[lang] = (stats[lang] || 0) + 1;
user.languageStats = stats;
}
updateLocalStreakForSolve(user, now);
user.coins = Math.max(0, user.coins || 0) + reward;
}
users[userIndex] = user;
saveLocalUsers(users);
return {
user: toAuthUser(user),
stats: buildLocalProgressStats(user.id, users),
};
}
function spendLocalCoins(token: string, amount: number): { user: AuthUser; stats: UserProgressStats } {
const users = loadLocalUsers();
const userId = token.slice(LOCAL_AUTH_TOKEN_PREFIX.length);
const userIndex = users.findIndex((candidate) => candidate.id === userId);
if (userIndex === -1) {
throw new Error('Not authenticated');
}
const user = normalizeLocalUser(users[userIndex]);
if (amount <= 0) {
throw new Error('Amount must be greater than 0.');
}
if (user.coins < amount) {
throw new Error('Not enough coins.');
}
user.coins -= amount;
users[userIndex] = user;
saveLocalUsers(users);
return {
user: toAuthUser(user),
stats: buildLocalProgressStats(user.id, users),
};
}
const REACHABILITY_HINT =
'Start the API with "npm run api". For a single-server launch, run "npm start" and open http://localhost:3000. For Vite development, run "npm run api" and "npm run dev", then open http://localhost:5173. If the UI is on another port, add VITE_API_BASE=http://localhost:3000 to .env and restart.';
export async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
const url = apiUrl(path);
const headers = new Headers(init?.headers);
const timeZone = clientTimeZone();
if (timeZone && !headers.has('X-Timezone')) {
headers.set('X-Timezone', timeZone);
}
try {
const response = await fetch(url, {
...init,
headers,
});
return response;
} catch (error) {
console.error('API fetch error:', { url, error });
throw new Error(`Cannot reach server (${url}). ${REACHABILITY_HINT}`);
}
}
async function parseJson(res: Response) {
const text = await res.text();
try {
return text ? JSON.parse(text) : {};
} catch (error) {
console.error('JSON parse error:', { text, error });
return { error: 'Invalid server response' };
}
}
export async function registerUser(
email: string,
password: string,
displayName: string,
): Promise<{ token: string; user: AuthUser }> {
try {
const res = await apiFetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, displayName }),
});
const data = await parseJson(res);
if (!res.ok) {
if (isLocalDevOrigin() && res.status >= 500) {
return registerLocalUser(email, password, displayName);
}
throw new Error(data.error || 'Registration failed');
}
return data as { token: string; user: AuthUser };
} catch (error) {
if (shouldUseLocalAuthFallback(error)) {
return registerLocalUser(email, password, displayName);
}
console.error('Registration error:', error);
throw error;
}
}
export async function loginUser(
email: string,
password: string,
): Promise<{ token: string; user: AuthUser }> {
try {
const res = await apiFetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await parseJson(res);
if (!res.ok) {
if (isLocalDevOrigin() && res.status >= 500) {
return loginLocalUser(email, password);
}
throw new Error(data.error || 'Login failed');
}
return data as { token: string; user: AuthUser };
} catch (error) {
if (shouldUseLocalAuthFallback(error)) {
return loginLocalUser(email, password);
}
console.error('Login error:', error);
throw error;
}
}
export async function fetchSessionUser(token: string): Promise<AuthUser | null> {
if (isLocalToken(token)) {
const users = loadLocalUsers();
const userId = token.slice(LOCAL_AUTH_TOKEN_PREFIX.length);
const userIndex = users.findIndex((u) => u.id === userId);
if (userIndex === -1) return null;
const user = normalizeLocalUser(users[userIndex]);
touchLocalStreak(user, new Date());
users[userIndex] = user;
saveLocalUsers(users);
return toAuthUser(user);
}
try {
const res = await apiFetch('/api/auth/me', {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return null;
const data = await parseJson(res);
return (data as { user: AuthUser }).user ?? null;
} catch (error) {
console.error('Fetch session user error:', error);
return null;
}
}
export async function updateProfile(
token: string,
data: { displayName?: string; oldPassword?: string; newPassword?: string },
): Promise<{ user: AuthUser }> {
if (isLocalToken(token)) {
const result = updateLocalUser(token, data);
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('user-updated', { detail: result.user }));
}
return result;
}
const res = await apiFetch('/api/auth/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(data),
});
const resData = await parseJson(res);
if (!res.ok) {
throw new Error(resData.error || 'Failed to update profile');
}
return resData as { user: AuthUser };
}
export async function addCoins(
token: string,
amount: number,
): Promise<{ user: AuthUser }> {
if (isLocalToken(token)) {
const result = updateLocalCoins(token, amount);
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('user-updated', { detail: result.user }));
}
return result;
}
const res = await apiFetch('/api/user/add-coins', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ amount }),
});
const resData = await parseJson(res);
if (!res.ok) {
throw new Error(resData.error || 'Failed to add coins');
}
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('user-updated', { detail: resData.user }));
}
return resData as { user: AuthUser };
}
function getSolveReward(difficulty: string) {
const normalizedDifficulty = difficulty.trim().toLowerCase();
if (normalizedDifficulty === 'easy') {
return 10;
}
if (normalizedDifficulty === 'medium') {
return 25;
}
if (normalizedDifficulty === 'hard') {
return 50;
}
return null;
}
export async function fetchUserProgress(
token: string,
): Promise<UserProgressStats> {
if (isLocalToken(token)) {
const localUser = findLocalUserByToken(token);
if (!localUser) {
throw new Error('Not authenticated');
}
return buildLocalProgressStats(localUser.id, loadLocalUsers());
}
const res = await apiFetch('/api/user/stats', {
headers: {
Authorization: `Bearer ${token}`,
},
});
const resData = await parseJson(res);
if (!res.ok) {
throw new Error(resData.error || 'Failed to load progress');
}
const typed = resData as { user?: AuthUser; stats: UserProgressStats };
if (typed.user && typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('user-updated', { detail: typed.user }));
}
return typed.stats;
}
export async function importUserProgress(
token: string,
data: { solvedQuestionIds: string[]; dailyActivity: Record<string, number> },
): Promise<{ user: AuthUser; stats: UserProgressStats }> {
if (isLocalToken(token)) {
const result = mergeLocalProgress(token, data);
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('user-updated', { detail: result.user }));
}
return result;
}
const res = await apiFetch('/api/user/import-progress', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(data),
});
const resData = await parseJson(res);
if (!res.ok) {
throw new Error(resData.error || 'Failed to import progress');
}
const typed = resData as { user: AuthUser; stats: UserProgressStats };
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('user-updated', { detail: typed.user }));
}
return typed;
}
export async function recordSolvedQuestion(
token: string,
data: { questionId: string; difficulty: string; language?: string },
): Promise<{ user: AuthUser; stats: UserProgressStats }> {
if (isLocalToken(token)) {
const result = recordLocalSolvedQuestion(token, data.questionId, data.difficulty, data.language);
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('user-updated', { detail: result.user }));
}
return result;
}
const res = await apiFetch('/api/user/solve-question', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(data),
});
const resData = await parseJson(res);
if (!res.ok) {
throw new Error(resData.error || 'Failed to record solved question');
}
const typed = resData as { user: AuthUser; stats: UserProgressStats };
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('user-updated', { detail: typed.user }));
}
return typed;
}
export async function spendCoins(
token: string,
amount: number,
): Promise<{ user: AuthUser; stats: UserProgressStats }> {
if (isLocalToken(token)) {
const result = spendLocalCoins(token, amount);
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('user-updated', { detail: result.user }));
}
return result;
}
const res = await apiFetch('/api/user/spend-coins', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ amount }),
});
const resData = await parseJson(res);
if (!res.ok) {
throw new Error(resData.error || 'Failed to spend coins');
}
const typed = resData as { user: AuthUser; stats: UserProgressStats };
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('user-updated', { detail: typed.user }));
}
return typed;
}
export async function fetchCoinHistory(token: string): Promise<CoinTransaction[]> {
if (isLocalToken(token)) {
const localUser = findLocalUserByToken(token);
if (!localUser || !localUser.coins) {
return [];
}
return [
{
id: 'legacy-balance',
userId: localUser.id,
amount: localUser.coins,
source: 'Initial Balance',
category: 'bonus',
createdAt: localUser.createdAt || new Date().toISOString(),
}
];
}
const res = await apiFetch('/api/user/coin-history', {
headers: {
Authorization: `Bearer ${token}`,
},
});
const resData = await parseJson(res);
if (!res.ok) {
throw new Error(resData.error || 'Failed to fetch coin history');
}
return resData as CoinTransaction[];
}