Spaces:
Sleeping
Sleeping
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // 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<T>(path: string, options?: RequestInit): Promise<T> { | |
| 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<string | { authority: string }>; | |
| 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<StreakDto> { | |
| try { | |
| return await request<StreakDto>('/api/student/streak'); | |
| } catch { | |
| return { userId: 0, currentStreak: 0, longestStreak: 0, lastActivityDate: new Date().toISOString() }; | |
| } | |
| } | |
| export async function fetchPracticeStats(): Promise<PracticeStatsDto> { | |
| try { | |
| return await request<PracticeStatsDto>('/api/student/practice/stats'); | |
| } catch { | |
| return { userId: 0, totalRecitations: 0, addedRecitationThisWeek: 0, averageAccuracy: 0, progressTrend: null }; | |
| } | |
| } | |
| export async function fetchMonthCount(): Promise<number> { | |
| try { | |
| const result = await request<number | string>('/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<RecitationStatsDto> { | |
| try { | |
| return await request<RecitationStatsDto>('/api/student/recitations/stats'); | |
| } catch { | |
| return { userId: 0, totalRecitations: 0, addedRecitationThisWeek: 0, averageAccuracy: 0, progressTrend: 0 }; | |
| } | |
| } | |
| export async function fetchTotalRecitations(): Promise<RecentRecitationDto[]> { | |
| try { | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| const raw = await request<any[]>('/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<RecentRecitationDto[]> { | |
| try { | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| const raw = await request<any[]>('/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<RecitationDetailDto | null> { | |
| try { | |
| return await request<RecitationDetailDto>(`/api/student/recitations/${id}`); | |
| } catch { | |
| return null; | |
| } | |
| } | |
| export async function submitRecitation(body: SubmitRecitationDto): Promise<RecitationDetailDto | null> { | |
| try { | |
| return await request<RecitationDetailDto>('/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<RecitationDetailDto> { | |
| // 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<any> { | |
| 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<DashboardData>; | |
| errors: Record<string, string>; | |
| }> { | |
| const [streak, practiceStats, monthCount, recitationStats, recentRecitations] = | |
| await Promise.allSettled([ | |
| fetchStreak(), | |
| fetchPracticeStats(), | |
| fetchMonthCount(), | |
| fetchRecitationStats(), | |
| fetchRecentRecitations(), | |
| ]); | |
| const data: Partial<DashboardData> = {}; | |
| const errors: Record<string, string> = {}; | |
| const resolve = <K extends keyof DashboardData>( | |
| key: K, | |
| result: PromiseSettledResult<DashboardData[K]>, | |
| ) => { | |
| 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<string, unknown>)['content'])) { | |
| data.recentRecitations = (raw as Record<string, unknown[]>)['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; |