import type { AuthMeResponse, AuthRequest, AuthResponse, DiagnosisRecord, DiseaseCatalogItem, DiseaseSlug, ImageClassificationRecord, ManualClassificationRequest, ManualClassificationRecord, RegisterRequest, ReviewDiagnosisRequest, DashboardSummary, } from '@zeavis/shared'; import { recordApiCall } from './telemetry'; export const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'https://zeavisedu.asepharyana.my.id'; const AUTH_TOKEN_KEY = 'zeavis_auth_token'; function getAuthToken(): string | null { try { return localStorage.getItem(AUTH_TOKEN_KEY); } catch { return null; } } export function setAuthToken(token: string | null) { try { if (token) { localStorage.setItem(AUTH_TOKEN_KEY, token); } else { localStorage.removeItem(AUTH_TOKEN_KEY); } } catch { // localStorage may throw in private browsing } } export interface ApiError extends Error { status: number; source?: 'uploader' | 'model-service' | 'unknown'; } async function fetchApi(endpoint: string, options?: RequestInit): Promise { const start = performance.now(); const url = `${apiBaseUrl}${endpoint}`; const token = getAuthToken(); const headers = new Headers(options?.headers); if (token) { headers.set('Authorization', `Bearer ${token}`); } if (options?.body && !options.method) { // auto-set Content-Type for JSON bodies } const response = await fetch(url, { credentials: 'include', ...options, headers, }); const duration = performance.now() - start; const method = options?.method ?? 'GET'; recordApiCall(method, endpoint, duration, response.status); if (!response.ok) { let errorMessage = `HTTP ${response.status}`; let source: 'uploader' | 'model-service' | 'unknown' | undefined; try { const errorData = await response.json(); if (errorData.error) { errorMessage = errorData.error; } if (errorData.source) { source = errorData.source; } } catch { } const error = new Error(errorMessage) as ApiError; error.status = response.status; error.source = source; throw error; } return response.json(); } function toImageClassificationRecord(diagnosis: DiagnosisRecord): ImageClassificationRecord { if (!diagnosis.disease) { throw new Error('Diagnosis does not include disease details'); } return { id: diagnosis.id, predictedDiseaseSlug: diagnosis.predictedDiseaseSlug || 'daun-sehat', confidence: diagnosis.confidence || 0, probabilities: diagnosis.predictions.map((p) => ({ diseaseSlug: p.diseaseSlug || 'daun-sehat', label: p.modelLabel, confidence: p.confidence, })), imageUrl: diagnosis.imageUrl, originalFileName: diagnosis.imageFileName, uploaderPublicId: diagnosis.uploaderPublicId, uploader: { public_id: diagnosis.uploaderPublicId, file_name: diagnosis.imageFileName, mime_type: diagnosis.imageMimeType, size_bytes: diagnosis.imageSizeBytes, file_type: diagnosis.imageMimeType.split('/')[1] || 'unknown', download_url: diagnosis.imageUrl, }, createdAt: diagnosis.createdAt, disease: diagnosis.disease, }; } export const apiClient = { // Auth methods async getMe(): Promise { return fetchApi('/api/v1/auth/me'); }, async register(payload: RegisterRequest): Promise { const result = await fetchApi('/api/v1/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (result.token) setAuthToken(result.token); return result; }, async login(payload: AuthRequest): Promise { const result = await fetchApi('/api/v1/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (result.token) setAuthToken(result.token); return result; }, async logout(): Promise<{ ok: boolean }> { const result = await fetchApi<{ ok: boolean }>('/api/v1/auth/logout', { method: 'POST' }); setAuthToken(null); return result; }, // Disease catalog methods async getDiseases(): Promise { return fetchApi('/api/v1/diseases'); }, async getDisease(slug: DiseaseSlug): Promise { return fetchApi(`/api/v1/diseases/${slug}`); }, // Manual classification methods async getManualClassifications(): Promise { return fetchApi('/api/v1/classifications/manual'); }, async createManualClassification( payload: ManualClassificationRequest, ): Promise { return fetchApi('/api/v1/classifications/manual', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(payload), }); }, // Diagnosis methods async getDiagnoses(): Promise { return fetchApi('/api/v1/diagnoses'); }, async getDiagnosis(id: string): Promise { return fetchApi(`/api/v1/diagnoses/${id}`); }, async createDiagnosis(file: File): Promise { const formData = new FormData(); formData.append('file', file); return fetchApi('/api/v1/diagnoses', { method: 'POST', body: formData, }); }, // Expert review methods async getExpertReviews(): Promise { return fetchApi('/api/v1/expert/reviews'); }, async reviewDiagnosis(id: string, payload: ReviewDiagnosisRequest): Promise { return fetchApi(`/api/v1/expert/reviews/${id}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); }, // Dashboard methods async getDashboardSummary(): Promise { return fetchApi('/api/v1/dashboard/summary'); }, // Backwards-compatible aliases for old image classification methods async getImageClassifications(): Promise { const diagnoses = await this.getDiagnoses(); return diagnoses.map(toImageClassificationRecord); }, async createImageClassification(file: File): Promise { const diagnosis = await this.createDiagnosis(file); return toImageClassificationRecord(diagnosis); }, };