/** * API Client for QQuiz Backend */ import axios from 'axios' import toast from 'react-hot-toast' export const API_BASE_URL = import.meta.env.VITE_API_URL || '/api' export const AUTH_TOKEN_STORAGE_KEY = 'access_token' const AUTH_USER_STORAGE_KEY = 'user' const PUBLIC_REQUEST_PATHS = ['/auth/login', '/auth/register'] const getRequestPath = (config) => { const url = config?.url || '' if (!url) return '' if (url.startsWith('http://') || url.startsWith('https://')) { try { return new URL(url).pathname } catch (error) { return url } } return url.startsWith('/') ? url : `/${url}` } const isPublicRequest = (config) => { if (config?.skipAuthHandling === true) { return true } const path = getRequestPath(config) return PUBLIC_REQUEST_PATHS.some((publicPath) => path.endsWith(publicPath)) } export const buildApiUrl = (path) => { const base = API_BASE_URL.endsWith('/') ? API_BASE_URL.slice(0, -1) : API_BASE_URL const normalizedPath = path.startsWith('/') ? path : `/${path}` return `${base}${normalizedPath}` } export const getAccessToken = () => localStorage.getItem(AUTH_TOKEN_STORAGE_KEY) export const clearAuthStorage = () => { localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY) localStorage.removeItem(AUTH_USER_STORAGE_KEY) } // Create axios instance const api = axios.create({ baseURL: API_BASE_URL, timeout: 30000, headers: { 'Content-Type': 'application/json' } }) // Request interceptor - Add auth token api.interceptors.request.use( (config) => { const token = getAccessToken() if (token && !isPublicRequest(config)) { config.headers = config.headers || {} config.headers.Authorization = `Bearer ${token}` } return config }, (error) => { return Promise.reject(error) } ) // Response interceptor - Handle errors api.interceptors.response.use( (response) => response, (error) => { const status = error.response?.status const message = error.response?.data?.detail || 'An error occurred' const requestConfig = error.config || {} const hasAuthHeader = Boolean( requestConfig.headers?.Authorization || requestConfig.headers?.authorization ) if (status === 401 && !isPublicRequest(requestConfig) && hasAuthHeader) { clearAuthStorage() if (window.location.pathname !== '/login') { window.location.href = '/login' } toast.error('Session expired. Please login again.') } else if (status === 401) { toast.error(message) } else if (status === 403) { toast.error('Permission denied') } else if (status === 429) { toast.error(message) } else if (status >= 500) { toast.error('Server error. Please try again later.') } else { toast.error(message) } return Promise.reject(error) } ) // ============ Auth APIs ============ export const authAPI = { register: (username, password) => api.post('/auth/register', { username, password }, { skipAuthHandling: true }), login: (username, password) => api.post('/auth/login', { username, password }, { skipAuthHandling: true }), getCurrentUser: () => api.get('/auth/me'), changePassword: (oldPassword, newPassword) => api.post('/auth/change-password', null, { params: { old_password: oldPassword, new_password: newPassword } }) } // ============ Exam APIs ============ export const examAPI = { // Create exam with first document create: (title, file, isRandom = false) => { const formData = new FormData() formData.append('title', title) formData.append('file', file) formData.append('is_random', isRandom) return api.post('/exams/create', formData, { headers: { 'Content-Type': 'multipart/form-data' } }) }, // Append document to existing exam appendDocument: (examId, file) => { const formData = new FormData() formData.append('file', file) return api.post(`/exams/${examId}/append`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }) }, // Get user's exam list getList: (skip = 0, limit = 20) => api.get('/exams/', { params: { skip, limit } }), // Get exam detail getDetail: (examId) => api.get(`/exams/${examId}`), // Delete exam delete: (examId) => api.delete(`/exams/${examId}`), // Update quiz progress updateProgress: (examId, currentIndex) => api.put(`/exams/${examId}/progress`, { current_index: currentIndex }) } // ============ Question APIs ============ export const questionAPI = { // Get all questions (Question Bank) getAll: (skip = 0, limit = 50, examId = null) => { const params = { skip, limit } if (examId) params.exam_id = examId return api.get('/questions/', { params }) }, // Get all questions for an exam getExamQuestions: (examId, skip = 0, limit = 50) => api.get(`/questions/exam/${examId}/questions`, { params: { skip, limit } }), // Get current question (based on exam's current_index) getCurrentQuestion: (examId) => api.get(`/questions/exam/${examId}/current`), // Get question by ID getById: (questionId) => api.get(`/questions/${questionId}`), // Check answer checkAnswer: (questionId, userAnswer) => api.post('/questions/check', { question_id: questionId, user_answer: userAnswer }) } // ============ Mistake APIs ============ export const mistakeAPI = { // Get user's mistake book getList: (skip = 0, limit = 50, examId = null) => { const params = { skip, limit } if (examId) params.exam_id = examId return api.get('/mistakes/', { params }) }, // Add to mistake book add: (questionId) => api.post('/mistakes/add', { question_id: questionId }), // Remove from mistake book by mistake ID remove: (mistakeId) => api.delete(`/mistakes/${mistakeId}`), // Remove from mistake book by question ID removeByQuestionId: (questionId) => api.delete(`/mistakes/question/${questionId}`) } // ============ Admin APIs ============ export const adminAPI = { // Config getConfig: () => api.get('/admin/config'), updateConfig: (config) => api.put('/admin/config', config), // Users getUsers: (skip = 0, limit = 50, search = null) => api.get('/admin/users', { params: { skip, limit, search } }), createUser: (username, password, is_admin = false) => api.post('/admin/users', { username, password, is_admin }), updateUser: (userId, data) => api.put(`/admin/users/${userId}`, data), deleteUser: (userId) => api.delete(`/admin/users/${userId}`), // Statistics getStatistics: () => api.get('/admin/statistics'), getHealth: () => api.get('/admin/health'), // Export exportUsers: () => api.get('/admin/export/users', { responseType: 'blob' }), exportStatistics: () => api.get('/admin/export/statistics', { responseType: 'blob' }) } export default api