veza/apps/web/src/services/api.ts
2025-12-17 08:07:35 -05:00

453 lines
13 KiB
TypeScript

import axios, { type AxiosInstance } from 'axios';
import { z } from 'zod';
import type {
ApiError,
AuthTokens,
LoginRequest,
RegisterRequest,
User,
PaginatedResponse,
Track,
LibraryItem,
Conversation,
} from '@/types';
export type { Track };
// Configuration de base
// En production, les variables d'environnement doivent être définies
const API_BASE_URL = (() => {
const url = import.meta.env.VITE_API_URL;
if (!url) {
if (import.meta.env.PROD) {
throw new Error('VITE_API_URL must be defined in production');
}
// Fallback uniquement en développement
return 'http://127.0.0.1:8080/api/v1';
}
return url;
})();
const WS_BASE_URL = (() => {
const url = import.meta.env.VITE_WS_URL;
if (!url) {
if (import.meta.env.PROD) {
throw new Error('VITE_WS_URL must be defined in production');
}
// Fallback uniquement en développement
return 'ws://127.0.0.1:8081/ws';
}
return url;
})();
// Schémas de validation Zod
const UserSchema = z.object({
id: z.string(),
username: z.string(),
email: z.string().email(),
first_name: z.string().optional(),
last_name: z.string().optional(),
role: z.enum(['user', 'admin', 'super_admin']),
is_active: z.boolean(),
is_verified: z.boolean(),
created_at: z.string(),
last_login_at: z.string().optional(),
avatar_url: z.string().optional(),
bio: z.string().optional(),
});
const AuthTokensSchema = z.object({
access_token: z.string(),
refresh_token: z.string(),
expires_in: z.number(),
});
const ApiErrorSchema = z.object({
message: z.string(),
code: z.string().optional(),
details: z.record(z.string(), z.unknown()).optional(),
});
// Classe principale du service API
export class ApiService {
private client: AxiosInstance;
private refreshPromise: Promise<string> | null = null;
constructor() {
this.client = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
this.setupInterceptors();
}
private setupInterceptors() {
// Intercepteur de requête pour ajouter le token
this.client.interceptors.request.use(
(config) => {
const token = this.getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error),
);
// Intercepteur de réponse pour gérer les erreurs 401
this.client.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const newToken = await this.refreshAccessToken();
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return this.client(originalRequest);
} catch (refreshError) {
this.clearTokens();
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(this.handleError(error));
},
);
}
private getAccessToken(): string | null {
return localStorage.getItem('access_token');
}
private getRefreshToken(): string | null {
return localStorage.getItem('refresh_token');
}
private setTokens(tokens: AuthTokens): void {
localStorage.setItem('access_token', tokens.access_token);
localStorage.setItem('refresh_token', tokens.refresh_token);
}
private clearTokens(): void {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
}
private async refreshAccessToken(): Promise<string> {
if (this.refreshPromise) {
return this.refreshPromise;
}
this.refreshPromise = this.performRefresh();
try {
const token = await this.refreshPromise;
return token;
} finally {
this.refreshPromise = null;
}
}
private async performRefresh(): Promise<string> {
const refreshToken = this.getRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token available');
}
try {
const response = await axios.post(`${API_BASE_URL}/auth/refresh`, {
refresh_token: refreshToken,
});
const tokens = AuthTokensSchema.parse(response.data);
this.setTokens(tokens);
return tokens.access_token;
} catch (error) {
this.clearTokens();
throw error;
}
}
private handleError(error: any): ApiError {
if (error.response?.data) {
return ApiErrorSchema.parse(error.response.data);
}
return {
message: error.message || 'An unexpected error occurred',
code: 'UNKNOWN_ERROR',
};
}
// Méthodes d'authentification
async login(
credentials: LoginRequest,
): Promise<{ user: User; tokens: AuthTokens }> {
const response = await this.client.post('/auth/login', credentials);
// Backend returns { success: true, data: { user, token } }
const { user, token } = response.data.data;
const validatedUser = UserSchema.parse(user);
const validatedTokens = AuthTokensSchema.parse(token);
this.setTokens(validatedTokens);
return { user: validatedUser, tokens: validatedTokens };
}
async register(
userData: RegisterRequest,
): Promise<{ user: User; tokens: AuthTokens }> {
const response = await this.client.post('/auth/register', userData);
// Backend returns { success: true, data: { user, token } }
const { user, token } = response.data.data;
const validatedUser = UserSchema.parse(user);
const validatedTokens = AuthTokensSchema.parse(token);
this.setTokens(validatedTokens);
return { user: validatedUser, tokens: validatedTokens };
}
async logout(): Promise<void> {
try {
await this.client.post('/auth/logout');
} finally {
this.clearTokens();
}
}
async getCurrentUser(): Promise<User> {
const response = await this.client.get('/auth/me');
return UserSchema.parse(response.data);
}
// Méthodes pour les utilisateurs
async getUsers(params?: {
page?: number;
limit?: number;
search?: string;
}): Promise<PaginatedResponse<User>> {
const response = await this.client.get('/users', { params });
return response.data;
}
async getUser(id: string): Promise<User> {
const response = await this.client.get(`/users/${id}`);
return UserSchema.parse(response.data);
}
async updateUser(id: string, data: Partial<User>): Promise<User> {
const response = await this.client.put(`/users/${id}`, data);
return UserSchema.parse(response.data);
}
// Méthodes pour les tracks
async getTracks(params?: {
page?: number;
limit?: number;
search?: string;
artist?: string;
}): Promise<PaginatedResponse<Track>> {
const response = await this.client.get('/tracks', { params });
// Ensure response.data maps to PaginatedResponse<Track>
// If backend returns { tracks: [], total: ... }, we might need mapping
// But let's assume standard response for now or fix if types mismatch
return response.data;
}
async getTrack(id: string): Promise<Track> {
const response = await this.client.get(`/tracks/${id}`);
return response.data;
}
async uploadTrack(
file: File,
metadata: { title: string; artist: string; album?: string },
): Promise<Track> {
const formData = new FormData();
formData.append('file', file);
formData.append('title', metadata.title);
formData.append('artist', metadata.artist);
if (metadata.album) {
formData.append('album', metadata.album);
}
const response = await this.client.post('/tracks', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
}
async deleteTrack(id: string): Promise<void> {
await this.client.delete(`/tracks/${id}`);
}
// Méthodes pour la bibliothèque
// Note: Le backend n'a pas d'endpoint /library, on utilise /tracks à la place
async getLibraryItems(params?: {
page?: number;
limit?: number;
type?: string;
}): Promise<PaginatedResponse<LibraryItem>> {
// Utiliser /tracks au lieu de /library qui n'existe pas
const response = await this.client.get('/tracks', { params });
return response.data;
}
async uploadFile(
file: File,
metadata: { title: string; description?: string },
): Promise<LibraryItem> {
const formData = new FormData();
formData.append('file', file);
formData.append('title', metadata.title);
if (metadata.description) {
formData.append('description', metadata.description);
}
const response = await this.client.post('/library', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
}
async toggleFavorite(itemId: string): Promise<LibraryItem> {
const response = await this.client.post(`/library/${itemId}/favorite`);
return response.data;
}
// Méthodes pour les messages
async getMessages(
conversationId: string,
params?: { page?: number; limit?: number },
): Promise<PaginatedResponse<any>> {
const response = await this.client.get(`/messages`, {
params: { conversation_id: conversationId, ...params },
});
return response.data;
}
async sendMessage(
conversationId: string,
content: string,
parentMessageId?: string,
): Promise<any> {
const response = await this.client.post('/messages', {
conversation_id: conversationId,
content,
parent_message_id: parentMessageId,
});
return response.data;
}
// Méthodes pour les conversations
async getConversations(): Promise<Conversation[]> {
const response = await this.client.get('/conversations');
// Backend retourne { conversations: [...], total: X }
const conversations = response.data.conversations || [];
// Convertir les IDs de int64 à string pour le frontend
return conversations.map((conv: any) => ({
...conv,
id: String(conv.id), // Backend retourne int64, frontend attend string
name: conv.name || `Conversation ${conv.id}`,
participants: conv.participants || [],
}));
}
async createConversation(params: {
name: string;
description?: string;
type?: 'public' | 'private' | 'direct';
is_private?: boolean;
}): Promise<Conversation> {
const response = await this.client.post('/conversations', {
name: params.name,
description: params.description || '',
type: params.type || 'public',
is_private: params.is_private || false,
});
// Convertir la réponse backend en format frontend
const data = response.data;
return {
id: String(data.id), // Backend retourne int64, frontend attend string
name: data.name,
description: data.description,
type:
data.type === 'public' ||
data.type === 'private' ||
data.type === 'direct'
? data.type
: 'public',
is_private: data.is_private || false,
created_by: data.created_by,
participants: data.participants || [],
created_at: data.created_at,
updated_at: data.updated_at,
};
}
// Méthodes utilitaires
getWebSocketUrl(): string {
const token = this.getAccessToken();
return `${WS_BASE_URL}?token=${token}`;
}
isAuthenticated(): boolean {
return !!this.getAccessToken();
}
// Chat methods alias/helpers
async getChatStats(): Promise<{
active_users: number;
total_messages: number;
rooms_active: number;
}> {
const response = await this.client.get('/chat/stats');
return response.data;
}
async getChatMessages(params: {
room: string;
limit?: number;
}): Promise<{ success: boolean; data: any[] }> {
// Assuming room name maps to conversation ID or backend handles it
// Using existing getMessages logic or creating specific endpoint call
try {
// If room is 'general', we might need to look it up or use a specific ID.
// For now, assuming room IS the conversationId or name that backend resolves.
// But typically getMessages expects conversation_id.
// Let's call /messages directly with query params
const response = await this.client.get('/messages', {
params: { conversation_id: params.room, limit: params.limit },
});
return { success: true, data: response.data.data };
} catch (error) {
console.error('Failed to get chat messages', error);
return { success: false, data: [] };
}
}
async sendChatMessage(data: {
content: string;
author: string;
room: string;
is_direct: boolean;
}): Promise<any> {
return this.sendMessage(data.room, data.content);
}
}
// Instance singleton
export const apiService = new ApiService();