Quran_Tech_Server / F_Pro /src /lib /api /studentApi.ts
aboalaa147's picture
Initial deployment
eb6a2f9
Raw
History Blame Contribute Delete
16.1 kB
// ─────────────────────────────────────────────────────────────────────────────
// 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;