import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios'; import { User, LoginRequest, RegisterRequest, AuthTokens, Medication, CreateMedicationRequest, UpdateMedicationRequest, DrugInteraction, CheckInteractionRequest, CheckNewMedicationRequest, CreateHealthStatRequest, ApiError, DoseLog, LogDoseRequest, AdherenceStats, MedicationWireResponse, AppointmentWireResponse, ProfileWireResponse, CreateProfileRequest, UpdateProfileRequest, HealthStatWireResponse, UpdateHealthStatRequest, EncryptedFieldWire, CreateAppointmentRequest, UpdateAppointmentRequest, } from '../types/api'; // API base URL. In dev this is "/api", proxied by the Vite dev server to the // backend (see vite.config.ts). For a production build pointed at a remote // backend, set VITE_API_URL to the absolute URL at build time. const API_BASE = import.meta.env.VITE_API_URL || '/api'; class ApiService { private client: AxiosInstance; private token: string | null = null; private refreshToken: string | null = null; private refreshing: Promise | null = null; constructor() { this.client = axios.create({ baseURL: API_BASE, headers: { 'Content-Type': 'application/json', }, }); // Load tokens from localStorage (single source of truth shared with the // zustand auth store's persist). this.token = localStorage.getItem('token'); this.refreshToken = localStorage.getItem('refresh_token'); if (this.token) { this.setAuthHeader(this.token); } // Request interceptor: attach the access token. this.client.interceptors.request.use( (config) => { if (this.token) { config.headers.Authorization = `Bearer ${this.token}`; } return config; }, (error) => Promise.reject(error), ); // Response interceptor: on 401, attempt ONE silent refresh; if it fails, // clear credentials and redirect to /login. this.client.interceptors.response.use( (response) => response, async (error: AxiosError) => { const original = error.config as InternalAxiosRequestConfig & { _retried?: boolean; _noRefresh?: boolean; }; if ( error.response?.status === 401 && !original._retried && !original._noRefresh && this.refreshToken ) { original._retried = true; const newToken = await this.doRefresh(); if (newToken) { original.headers!.Authorization = `Bearer ${newToken}`; return this.client.request(original); } // Refresh failed: drop credentials and bounce to login. this.clearCredentials(); if (window.location.pathname !== '/login') { window.location.href = '/login'; } return Promise.reject(this.handleError(error)); } return Promise.reject(this.handleError(error)); }, ); } private handleError(error: AxiosError): ApiError { if (error.response) { const data = error.response.data as Record; return { message: (data.error as string) || (data.message as string) || 'An error occurred', code: String(error.response.status), details: data, }; } else if (error.request) { return { message: 'No response from server', code: 'NETWORK_ERROR', }; } else { return { message: error.message || 'Unknown error', code: 'UNKNOWN', }; } } private setAuthHeader(token: string) { this.client.defaults.headers.common['Authorization'] = `Bearer ${token}`; } setTokens(accessToken: string, refreshToken: string) { this.token = accessToken; this.refreshToken = refreshToken; localStorage.setItem('token', accessToken); localStorage.setItem('refresh_token', refreshToken); this.setAuthHeader(accessToken); } setToken(token: string) { this.token = token; localStorage.setItem('token', token); this.setAuthHeader(token); } getToken(): string | null { return this.token; } getRefreshToken(): string | null { return this.refreshToken; } clearCredentials() { this.token = null; this.refreshToken = null; localStorage.removeItem('token'); localStorage.removeItem('refresh_token'); delete this.client.defaults.headers.common['Authorization']; } /** Log out: revoke the refresh token server-side, then clear local state. */ async logout() { if (this.refreshToken) { try { await this.client.post('/auth/logout', { refresh_token: this.refreshToken }); } catch { // Best-effort: even if the server call fails, drop local credentials. } } this.clearCredentials(); } /** Perform a single in-flight refresh, deduped across concurrent requests. */ private async doRefresh(): Promise { if (this.refreshing) return this.refreshing; if (!this.refreshToken) return null; this.refreshing = (async () => { try { const response = await this.client.post('/auth/refresh', { refresh_token: this.refreshToken, }); const { token, refresh_token } = response.data; this.setTokens(token, refresh_token); return token; } catch { return null; } finally { this.refreshing = null; } })(); return this.refreshing; } // ---- Authentication ---- async login(email: string, _password: string): Promise { // _password is used in the request body below; renamed only to satisfy the // interface signature kept for store compatibility. const response = await this.client.post('/auth/login', { email, password: _password, }); this.setTokens(response.data.token, response.data.refresh_token); return response.data; } async register(data: RegisterRequest): Promise { const response = await this.client.post('/auth/register', data); this.setTokens(response.data.token, response.data.refresh_token); return response.data; } async getCurrentUser(): Promise { const response = await this.client.get('/users/me'); return response.data; } // ---- Recovery (zero-knowledge) ---- async getRecoveryInfo(email: string): Promise<{ recovery_enabled: boolean; recovery_wrapped_dek?: string; recovery_wrapped_dek_iv?: string }> { const response = await this.client.get('/auth/recovery-info', { params: { email } }); return response.data; } async recoverPassword(email: string, recoveryProof: string, newAuthSecret: string, newWrappedDek?: string, newWrappedDekIv?: string): Promise { await this.client.post('/auth/recover-password', { email, recovery_phrase: recoveryProof, new_password: newAuthSecret, new_wrapped_dek: newWrappedDek, new_wrapped_dek_iv: newWrappedDekIv, }); } // ---- Profiles (Phase A2: multi-profile, per-profile DEKs) ---- async listProfiles(): Promise { const response = await this.client.get('/profiles'); return response.data; } async createProfile(req: CreateProfileRequest): Promise { const response = await this.client.post('/profiles', req); return response.data; } async getProfile(profileId: string): Promise { const response = await this.client.get(`/profiles/${profileId}`); return response.data; } async updateProfile(profileId: string, req: UpdateProfileRequest): Promise { const response = await this.client.put(`/profiles/${profileId}`, req); return response.data; } async deleteProfile(profileId: string): Promise { await this.client.delete(`/profiles/${profileId}`); } // ---- Medications (zero-knowledge: opaque encrypted blobs) ---- async getMedications(profileId?: string): Promise { const params = profileId ? { profile_id: profileId } : undefined; const response = await this.client.get('/medications', { params }); return response.data; } async getMedication(id: string): Promise { const response = await this.client.get(`/medications/${id}`); return response.data; } async createMedication(data: CreateMedicationRequest): Promise { const response = await this.client.post('/medications', data); return response.data; } async updateMedication(id: string, data: UpdateMedicationRequest): Promise { const response = await this.client.post(`/medications/${id}`, data); return response.data; } async deleteMedication(id: string): Promise { await this.client.post(`/medications/${id}/delete`); } // ---- Dose logging + adherence (not encrypted — counts/flags only) ---- async logDose(medicationId: string, req: LogDoseRequest): Promise { const response = await this.client.post(`/medications/${medicationId}/log`, req); return response.data; } async getAdherence(medicationId: string): Promise { const response = await this.client.get(`/medications/${medicationId}/adherence`); return response.data; } // ---- Appointments (zero-knowledge: opaque encrypted blobs) ---- async getAppointments(status?: string, profileId?: string): Promise { const params: Record = {}; if (status) params.status = status; if (profileId) params.profile_id = profileId; const response = await this.client.get('/appointments', { params: Object.keys(params).length ? params : undefined, }); return response.data; } async getAppointment(id: string): Promise { const response = await this.client.get(`/appointments/${id}`); return response.data; } async createAppointment(data: CreateAppointmentRequest): Promise { const response = await this.client.post('/appointments', data); return response.data; } async updateAppointment(id: string, data: UpdateAppointmentRequest): Promise { const response = await this.client.post(`/appointments/${id}`, data); return response.data; } async deleteAppointment(id: string): Promise { await this.client.post(`/appointments/${id}/delete`); } // ---- Drug Interactions (Phase 2.8) ---- async checkInteractions(medications: string[]): Promise { const response = await this.client.post('/interactions/check', { medications, }); return response.data; } async checkNewMedication(name: string, dosage: string): Promise { const response = await this.client.post('/interactions/check-new', { name, dosage, }); return response.data; } // ---- Health Statistics (zero-knowledge: opaque encrypted blobs) ---- async getHealthStats(profileId?: string): Promise { const params = profileId ? { profile_id: profileId } : undefined; const response = await this.client.get('/health-stats', { params }); return response.data; } async getHealthStat(id: string): Promise { const response = await this.client.get(`/health-stats/${id}`); return response.data; } async createHealthStat(data: CreateHealthStatRequest): Promise { const response = await this.client.post('/health-stats', data); return response.data; } async updateHealthStat(id: string, data: UpdateHealthStatRequest): Promise { const response = await this.client.put(`/health-stats/${id}`, data); return response.data; } async deleteHealthStat(id: string): Promise { await this.client.delete(`/health-stats/${id}`); } // NOTE: lab-results endpoints are intentionally absent — the backend has no // /lab-results routes yet. The LabResult type stays in types/api.ts for when // those routes land. } // Export singleton instance export const apiService = new ApiService(); export default apiService; // Satisfy the unused-import linter for types referenced only via generics above. export type { LoginRequest, CheckInteractionRequest, CheckNewMedicationRequest, };