// ───────────────────────────────────────────────────────────────────────────── // src/lib/api/studentApi.ts // Centralised API layer for the Student Dashboard. // All fetch calls, types, and error handling live here. // ───────────────────────────────────────────────────────────────────────────── import { getApiBaseUrl } from './config'; const BASE_URL = getApiBaseUrl(); const TOKEN_KEY = 'authToken'; // ─── Custom Error Class ─────────────────────────────────────────────────────── export class ApiError extends Error { public readonly status: number; public readonly path: string; constructor(status: number, message: string, path: string) { super(`[${status}] ${path} → ${message}`); this.status = status; this.path = path; this.name = 'ApiError'; } } // ─── Shared helper ──────────────────────────────────────────────────────────── function getHeaders(): HeadersInit { const token = localStorage.getItem(TOKEN_KEY) ?? ''; return { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), }; } async function request(path: string, options?: RequestInit): Promise { const url = `${BASE_URL}${path}`; // Guard: if token exists but is expired, clear it and throw immediately // instead of sending a request that will always return 401/403 const token = localStorage.getItem(TOKEN_KEY); if (token) { try { const payload = JSON.parse(atob(token.split('.')[1])); if (payload?.exp && payload.exp * 1000 < Date.now()) { localStorage.removeItem(TOKEN_KEY); throw new ApiError(401, 'Session expired — please log in again', path); } } catch (e) { if (e instanceof ApiError) throw e; // malformed token — remove and continue (server will reject) localStorage.removeItem(TOKEN_KEY); } } try { const res = await fetch(url, { ...options, headers: { ...getHeaders(), ...options?.headers }, }); if (!res.ok) { const message = await res.text().catch(() => res.statusText); throw new ApiError(res.status, message, path); } const text = await res.text(); if (!text) return {} as T; try { return JSON.parse(text) as T; } catch { return text as unknown as T; } } catch (error) { if (error instanceof ApiError) throw error; throw new Error(`Network error while calling ${path}`); } } // ─── Response Types ─────────────────────────────────────────────────────────── export interface StreakDto { userId: number; currentStreak: number; longestStreak: number; lastActivityDate: string; } export interface PracticeStatsDto { userId: number; totalRecitations: number; addedRecitationThisWeek: number; averageAccuracy: number; progressTrend: string | null; } export interface RecitationStatsDto { userId: number; totalRecitations: number; addedRecitationThisWeek: number | null; averageAccuracy: number; progressTrend: number; } export interface RecentRecitationDto { recitationId: number; name?: string; surahName?: string; ayahStart?: number; ayahEnd?: number; createdAt: string; wordScore: number; tashkeelScore: number; overallScore?: number; } export interface totalRecitationDto { recitationId: number; name?: string; surahName?: string; ayahStart?: number; ayahEnd?: number; createdAt: string; wordScore: number; tashkeelScore: number; overallScore?: number; } export interface RecitationDetailDto { recitationId: number; surahName: string; startAyah: number; endAyah: number; recitationText: string; userText?: string; wordScore: number; tashkeelScore: number; overallScore: number; wordMistakesCount: number; tashkeelMistakesCount: number; tajweedMistakesCount?: number; incorrectWords: string[]; tashkeelMistakes: string[]; tajweedMistakes?: string[]; feedback?: string; createdAt: string; } export interface SubmitRecitationDto { surahName: string; ayahStart: number; ayahEnd: number; userText: string; // kept for backward compat } export interface SubmitRecitationAudioDto { surahName: string; surahNumber: number; audio: Blob | File; // actual audio file sent as FormData } export interface DashboardData { streak: StreakDto; practiceStats: PracticeStatsDto; monthCount: number; recitationStats: RecitationStatsDto; recentRecitations: RecentRecitationDto[]; } export interface UserInfo { role?: string; userType?: string; authorities?: Array; userId?: number; id?: number; // backend sends "id" not "userId" email?: string; sub?: string; exp?: number; // expiry unix timestamp iat?: number; } // ─── Helper Functions ───────────────────────────────────────────────────────── export function getUserInfoFromToken(): UserInfo | null { const token = localStorage.getItem(TOKEN_KEY); if (!token) return null; try { const parts = token.split('.'); if (parts.length !== 3) return null; const payload = JSON.parse(atob(parts[1])); return payload; } catch { return null; } } export function isStudent(): boolean { const userInfo = getUserInfoFromToken(); if (!userInfo) return false; // Check role / userType fields const role = userInfo.role || userInfo.userType; if (role && role.toString().toUpperCase().includes('STUDENT')) return true; // Check Spring-Security-style authorities array if (Array.isArray(userInfo.authorities)) { return userInfo.authorities.some((a) => { const str = typeof a === 'string' ? a : a?.authority ?? ''; return str.toUpperCase().includes('STUDENT'); }); } return false; } /** * Check if the stored token is expired */ export function isTokenExpired(): boolean { const userInfo = getUserInfoFromToken(); if (!userInfo?.exp) return true; // exp is in seconds, Date.now() is in milliseconds return userInfo.exp * 1000 < Date.now(); } export function isAuthenticated(): boolean { if (!localStorage.getItem(TOKEN_KEY)) return false; if (isTokenExpired()) { // Auto-clean expired token removeToken(); return false; } return true; } export function getToken(): string | null { return localStorage.getItem(TOKEN_KEY); } export function setToken(token: string): void { localStorage.setItem(TOKEN_KEY, token); } export function removeToken(): void { localStorage.removeItem(TOKEN_KEY); } // ─── API Functions ──────────────────────────────────────────────────────────── export async function fetchStreak(): Promise { try { return await request('/api/student/streak'); } catch { return { userId: 0, currentStreak: 0, longestStreak: 0, lastActivityDate: new Date().toISOString() }; } } export async function fetchPracticeStats(): Promise { try { return await request('/api/student/practice/stats'); } catch { return { userId: 0, totalRecitations: 0, addedRecitationThisWeek: 0, averageAccuracy: 0, progressTrend: null }; } } export async function fetchMonthCount(): Promise { try { const result = await request('/api/student/practice/month-count'); // Backend might return a plain number or a JSON-wrapped number return typeof result === 'number' ? result : parseInt(String(result), 10) || 0; } catch { return 0; } } export async function fetchRecitationStats(): Promise { try { return await request('/api/student/recitations/stats'); } catch { return { userId: 0, totalRecitations: 0, addedRecitationThisWeek: 0, averageAccuracy: 0, progressTrend: 0 }; } } export async function fetchTotalRecitations(): Promise { try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const raw = await request('/api/student/recitations'); if (!raw || !Array.isArray(raw)) return []; return raw.map((r) => { const wordScore = Number(r.wordScore ?? 0); const tashkeelScore = Number(r.tashkeelScore ?? 0); const overallScore = r.overallScore != null ? Number(r.overallScore) : Math.round(((wordScore + tashkeelScore) / 2) * 100) / 100; return { ...r, recitationId: r.recitationId ?? r.reciattionId ?? r.id, name: r.name || r.surahName || undefined, wordScore, tashkeelScore, overallScore, createdAt: r.createdAt || r.evaluatedAt || new Date().toISOString(), } as RecentRecitationDto; }); } catch { return []; } } export async function fetchRecentRecitations(): Promise { try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const raw = await request('/api/student/recitations/recent'); if (!raw || !Array.isArray(raw)) return []; return raw.map((r) => { const wordScore = Number(r.wordScore ?? 0); const tashkeelScore = Number(r.tashkeelScore ?? 0); const overallScore = r.overallScore != null ? Number(r.overallScore) : Math.round(((wordScore + tashkeelScore) / 2) * 100) / 100; return { ...r, recitationId: r.recitationId ?? r.reciattionId ?? r.id, name: r.name || r.surahName || undefined, wordScore, tashkeelScore, overallScore, createdAt: r.createdAt || r.evaluatedAt || new Date().toISOString(), } as RecentRecitationDto; }); } catch { return []; } } export async function fetchRecitationDetail(id: number): Promise { try { return await request(`/api/student/recitations/${id}`); } catch { return null; } } export async function submitRecitation(body: SubmitRecitationDto): Promise { try { return await request('/api/student/recitations', { method: 'POST', body: JSON.stringify(body), }); } catch { return null; } } /** * POST /api/student/recitations * Submit audio file for evaluation (multipart/form-data). * Throws ApiError so the caller can show meaningful error messages. */ export async function submitRecitationAudio( dto: SubmitRecitationAudioDto, ): Promise { // Guard: expired token const token = localStorage.getItem(TOKEN_KEY); if (token) { try { const payload = JSON.parse(atob(token.split('.')[1])); if (payload?.exp && payload.exp * 1000 < Date.now()) { localStorage.removeItem(TOKEN_KEY); throw new ApiError(401, 'Session expired — please log in again', '/api/student/recitations'); } } catch (e) { if (e instanceof ApiError) throw e; } } const form = new FormData(); // Determine file extension from mime type const mime = dto.audio instanceof File ? dto.audio.type : (dto.audio as Blob).type; const ext = mime.includes('wav') ? 'wav' : mime.includes('mpeg') || mime.includes('mp3') ? 'mp3' : mime.includes('m4a') ? 'm4a' : 'webm'; const filename = dto.audio instanceof File ? dto.audio.name : `recording.${ext}`; form.append('audio', dto.audio, filename); form.append('surahNumber', String(dto.surahNumber)); const authToken = localStorage.getItem(TOKEN_KEY) ?? ''; const res = await fetch(`${BASE_URL}/api/student/recitations`, { method: 'POST', headers: authToken ? { Authorization: `Bearer ${authToken}` } : {}, body: form, }); if (!res.ok) { const message = await res.text().catch(() => res.statusText); throw new ApiError(res.status, message, '/api/student/recitations'); } const text = await res.text(); if (!text) throw new ApiError(500, 'Empty response from server', '/api/student/recitations'); return JSON.parse(text) as RecitationDetailDto; } export async function fetchStatistics( timeRange: 'week' | 'month' | 'year' = 'month', // eslint-disable-next-line @typescript-eslint/no-explicit-any ): Promise { try { return await request(`/api/student/statistics?range=${timeRange}`); } catch { return { labels: [], accuracyData: [], countData: [] }; } } // ─── Convenience: load everything for the dashboard in one call ─────────────── export async function fetchDashboardData(): Promise<{ data: Partial; errors: Record; }> { const [streak, practiceStats, monthCount, recitationStats, recentRecitations] = await Promise.allSettled([ fetchStreak(), fetchPracticeStats(), fetchMonthCount(), fetchRecitationStats(), fetchRecentRecitations(), ]); const data: Partial = {}; const errors: Record = {}; const resolve = ( key: K, result: PromiseSettledResult, ) => { if (result.status === 'fulfilled') { data[key] = result.value; } else { const msg = result.reason?.message ?? 'Unknown error'; // Only surface real errors (not auth-related ones) to the UI const isAuthError = result.reason instanceof ApiError && (result.reason.status === 401 || result.reason.status === 403); if (!isAuthError) errors[key] = msg; // Safe defaults if (key === 'recentRecitations') { (data as any)[key] = []; } else if (key === 'monthCount') { (data as any)[key] = 0; } } }; resolve('streak', streak); resolve('practiceStats', practiceStats); resolve('monthCount', monthCount); resolve('recitationStats', recitationStats); resolve('recentRecitations', recentRecitations); // Defensive normalization: backend may return a paginated object instead of a plain array. if (data.recentRecitations !== undefined && !Array.isArray(data.recentRecitations)) { const raw = data.recentRecitations as unknown; if (raw && typeof raw === 'object' && Array.isArray((raw as Record)['content'])) { data.recentRecitations = (raw as Record)['content'] as RecentRecitationDto[]; } else { data.recentRecitations = []; } } // Normalise overallScore on each recitation if (Array.isArray(data.recentRecitations)) { data.recentRecitations = data.recentRecitations.map((rec) => ({ ...rec, overallScore: rec.overallScore ?? Math.round(((rec.wordScore + rec.tashkeelScore + (rec.totalScore ?? 0)) / 3) * 100) / 100, })); } return { data, errors }; } // ─── Default export ─────────────────────────────────────────────────────────── const studentApi = { fetchStreak, fetchPracticeStats, fetchMonthCount, fetchRecitationStats, fetchRecentRecitations, fetchRecitationDetail, submitRecitation, submitRecitationAudio, fetchStatistics, fetchDashboardData, getUserInfoFromToken, isStudent, isAuthenticated, getToken, setToken, removeToken, ApiError, }; export default studentApi;