453 lines
13 KiB
TypeScript
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();
|