/** * Cliente API para comunicación con el backend */ import { getToken } from './auth'; import { browser } from '$app/environment'; import { toast } from 'svelte-sonner'; // Normalize API_BASE_URL to remove trailing slash const API_BASE_URL = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, ''); export interface ApiResponse { data?: T; error?: string; validationErrors?: Array<{ field: string; message: string; code?: string; solution?: string[]; value?: any; }>; status: number; } let isRefreshing = false; let refreshSubscribers: ((token: string) => void)[] = []; /** * Agrega una petición a la cola de espera mientras se refresca el token */ function subscribeTokenRefresh(callback: (token: string) => void) { refreshSubscribers.push(callback); } /** * Notifica a todas las peticiones en espera que el token se ha refrescado */ function onTokenRefreshed(token: string) { refreshSubscribers.forEach((callback) => callback(token)); refreshSubscribers = []; } /** * Intenta refrescar el token usando el refresh token */ async function refreshToken(): Promise { if (!browser) return null; let refreshTokenValue = localStorage.getItem('refresh_token'); // Si no está en localStorage, intentar obtenerlo de las cookies if (!refreshTokenValue) { const getCookie = (name: string): string | null => { const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) return parts.pop()?.split(';').shift() || null; return null; }; refreshTokenValue = getCookie('refresh_token'); if (refreshTokenValue) { localStorage.setItem('refresh_token', refreshTokenValue); } } if (!refreshTokenValue) { console.error('❌ [API] No hay refresh token disponible'); return null; } try { const response = await fetch(`${API_BASE_URL}/v1/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: refreshTokenValue }), credentials: 'include' }); if (!response.ok) { console.error('❌ [API] Refresh token expirado o inválido, status:', response.status); // Si el refresh token también está expirado, limpiar todo localStorage.removeItem('access_token'); localStorage.removeItem('refresh_token'); // Limpiar cookies también document.cookie = 'access_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC'; document.cookie = 'refresh_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC'; // Redirigir al login después de un pequeño delay para que el usuario vea el mensaje setTimeout(() => { if (browser) { window.location.href = '/login'; } }, 2000); return null; } const data = await response.json(); // Guardar los nuevos tokens if (data.access_token) { localStorage.setItem('access_token', data.access_token); if (data.refresh_token) { localStorage.setItem('refresh_token', data.refresh_token); } // Actualizar también las cookies const isSecure = window.location.protocol === 'https:'; const secureFlag = isSecure ? '; Secure' : ''; document.cookie = `access_token=${data.access_token}; path=/; max-age=${60 * 60 * 24 * 7}; SameSite=Lax${secureFlag}`; if (data.refresh_token) { document.cookie = `refresh_token=${data.refresh_token}; path=/; max-age=${60 * 60 * 24 * 30}; SameSite=Lax${secureFlag}`; } // Actualizar el authStore si está disponible try { const { authStore } = await import('./auth'); authStore.setToken(data.access_token); } catch (e) { // Si no se puede importar authStore, no es crítico console.warn('⚠️ [API] No se pudo actualizar authStore:', e); } return data.access_token; } return null; } catch (error) { console.error('❌ [API] Error refreshing token:', error); return null; } } /** * Realiza una petición al API con manejo automático de refresh token */ async function fetchApi( endpoint: string, options: RequestInit = {}, retryCount = 0 ): Promise> { // Si ya estamos refrescando el token, esperar if (isRefreshing && retryCount === 0) { return new Promise((resolve) => { subscribeTokenRefresh((newToken) => { resolve(fetchApi(endpoint, options, 1)); }); }); } const token = getToken(); if (!token && !endpoint.includes('/auth/login')) { console.warn('⚠️ [API] No hay token disponible para', endpoint); } const headers: Record = { ...((options.headers as Record) || {}) }; // Only set Content-Type to application/json if not already set and body is not FormData if (!headers['Content-Type'] && !(options.body instanceof FormData)) { headers['Content-Type'] = 'application/json'; } if (token) { headers['Authorization'] = `Bearer ${token}`; } try { const response = await fetch(`${API_BASE_URL}${endpoint}`, { ...options, headers, credentials: 'include' // Importante: envía cookies con cada request }); // Si recibimos 401 o 403 y no es el endpoint de refresh, intentar refrescar el token if ((response.status === 401 || response.status === 403) && !endpoint.includes('/auth/refresh') && retryCount === 0) { // Si es 403 (Forbidden), mostrar toast de permisos insuficientes if (response.status === 403) { if (browser) { toast.error('No tienes permisos para realizar esta acción', { duration: 4000, description: 'Contacta a tu administrador si crees que esto es un error' }); } // Retornar el error 403 sin intentar refresh const data = await response.json(); return { error: data.detail || 'No tienes permisos para realizar esta acción', status: 403 }; } // Si es 401, intentar refrescar el token isRefreshing = true; try { const newToken = await refreshToken(); if (newToken) { // Token refrescado exitosamente onTokenRefreshed(newToken); isRefreshing = false; // Reintentar la petición original con el nuevo token return await fetchApi(endpoint, options, 1); } else { console.error('❌ [API] No se pudo refrescar el token'); isRefreshing = false; // Retornar error 401 para que la capa superior lo maneje return { error: 'Sesión expirada. Por favor, inicia sesión nuevamente.', status: 401 }; } } catch (refreshError) { console.error('❌ [API] Error al refrescar:', refreshError); isRefreshing = false; return { error: 'Error al refrescar la sesión', status: 401 }; } } // Manejar respuestas sin contenido (204 No Content) if (response.status === 204) { return { data: null as T, status: response.status }; } const data = await response.json(); if (!response.ok) { // Manejo especial para errores 422 (validation error) if (response.status === 422) { // Errores de validación personalizados (con array errors) if (data.errors && Array.isArray(data.errors)) { return { error: data.message || 'Error de validación', validationErrors: data.errors, status: response.status }; } // Errores de validación de FastAPI (con detail) else if (data.detail) { let errorMessage = 'Error de validación: '; // FastAPI devuelve errores de validación en data.detail como array if (Array.isArray(data.detail)) { const errors = data.detail.map((err: any) => { const field = err.loc ? err.loc.join('.') : 'campo desconocido'; return `${field}: ${err.msg}`; }).join(', '); errorMessage += errors; } else if (typeof data.detail === 'string') { errorMessage = data.detail; } else { errorMessage += JSON.stringify(data.detail); } return { error: errorMessage, status: response.status }; } } return { error: data.message || (typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail)) || 'Error en la petición', status: response.status }; } return { data, status: response.status }; } catch (error) { console.error(`❌ [API] Error de conexión en ${endpoint}:`, error); return { error: 'Error de conexión con el servidor', status: 0 }; } } // Métodos HTTP export const api = { get: (endpoint: string) => fetchApi(endpoint, { method: 'GET' }), post: (endpoint: string, body: any, options: RequestInit = {}) => fetchApi(endpoint, { method: 'POST', body: JSON.stringify(body), ...options }), put: (endpoint: string, body: any, options: RequestInit = {}) => fetchApi(endpoint, { method: 'PUT', body: JSON.stringify(body), ...options }), patch: (endpoint: string, body: any, options: RequestInit = {}) => fetchApi(endpoint, { method: 'PATCH', body: JSON.stringify(body), ...options }), delete: (endpoint: string, options: RequestInit = {}) => fetchApi(endpoint, { method: 'DELETE', ...options }), // Endpoints específicos auth: { login: (credentials: { username: string; password: string; tenant_slug: string }) => api.post('/v1/auth/login/', credentials), refresh: (refreshToken: string) => api.post('/v1/auth/refresh/', { refresh_token: refreshToken }), logout: (data: { refresh_token: string, username?: string }) => api.post('/v1/auth/logout', data, { keepalive: true }), me: () => api.get('/v1/auth/me/'), health: () => api.get('/health') }, tenants: { list: (page = 1, pageSize = 50) => api.get(`/v1/tenants/?page=${page}&page_size=${pageSize}`), get: (id: number) => api.get(`/v1/tenants/${id}/`), create: (data: any) => api.post('/v1/tenants/', data), update: (id: number, data: any) => api.put(`/v1/tenants/${id}/`, data) }, licenses: { get: (tenantId: number) => api.get(`/v1/licenses/tenant/${tenantId}/`), myLicense: () => api.get('/v1/licenses/my-license/'), usage: (tenantId: number) => api.get(`/v1/licenses/usage/${tenantId}/`), validate: (tenantId: number) => api.get(`/v1/licenses/validate/${tenantId}/`) }, imports: { upload: ( file: File, modelTarget: string, footerConfig: any, companyId: number, operationType: string, templateId?: string ) => { const formData = new FormData(); formData.append('file', file); if (footerConfig) { formData.append('footer_config', JSON.stringify(footerConfig)); } if (templateId) { formData.append('template_id', templateId); } const queryParams = new URLSearchParams({ company_id: String(companyId), operation_type: operationType || 'imp' }).toString(); return fetchApi(`/v1/a76/imports/upload/${modelTarget}?${queryParams}`, { method: 'POST', body: formData }); }, status: (jobId: string) => api.get(`/v1/a76/imports/${jobId}/status`), commit: (jobId: string, modelTarget: string) => api.post(`/v1/a76/imports/${jobId}/commit`, { model_target: modelTarget }) }, // CSV import for Agentes Aduanales (flujo propio en customs_brokers/imports) customsBrokerImports: { upload: (file: File, companyId: number) => { const formData = new FormData(); formData.append('file', file); return fetchApi( `/v1/a76/customs-brokers/imports/upload?company_id=${companyId}`, { method: 'POST', body: formData } ); }, status: (jobId: string) => api.get(`/v1/a76/customs-brokers/imports/${jobId}/status`), commit: (jobId: string) => api.post(`/v1/a76/customs-brokers/imports/${jobId}/commit`, {}) }, // CSV import for Clientes y Proveedores (flujo propio en clients_and_providers/imports) clientProviderImports: { upload: (file: File, companyId: number) => { const formData = new FormData(); formData.append('file', file); return fetchApi( `/v1/a76/clients-providers/imports/upload?company_id=${companyId}`, { method: 'POST', body: formData } ); }, status: (jobId: string) => api.get(`/v1/a76/clients-providers/imports/${jobId}/status`), commit: (jobId: string) => api.post(`/v1/a76/clients-providers/imports/${jobId}/commit`, {}) }, // CSV import for Tipos de Cambio (flujo en general_catalogs/exchange_rate/imports) exchangeRateImports: { upload: (file: File, companyId: number) => { const formData = new FormData(); formData.append('file', file); return fetchApi( `/v1/a76/exchange-rate/imports/upload?company_id=${companyId}`, { method: 'POST', body: formData } ); }, status: (jobId: string) => api.get(`/v1/a76/exchange-rate/imports/${jobId}/status`), commit: (jobId: string) => api.post(`/v1/a76/exchange-rate/imports/${jobId}/commit`, {}) }, // CSV import for Fracción Americana (us_tariff_fractions/imports) americanFractionImports: { upload: (file: File, companyId: number) => { const formData = new FormData(); formData.append('file', file); return fetchApi( `/v1/a76/us-tariff-fractions/imports/upload?company_id=${companyId}`, { method: 'POST', body: formData } ); }, status: (jobId: string) => api.get(`/v1/a76/us-tariff-fractions/imports/${jobId}/status`), commit: (jobId: string) => api.post(`/v1/a76/us-tariff-fractions/imports/${jobId}/commit`, {}) }, // CSV import for Pedimentos (pedimentos/imports) pedimentosImports: { upload: (file: File, companyId: number) => { const formData = new FormData(); formData.append('file', file); return fetchApi( `/v1/a76/pedimentos/imports/upload?company_id=${companyId}`, { method: 'POST', body: formData } ); }, status: (jobId: string) => api.get(`/v1/a76/pedimentos/imports/${jobId}/status`), commit: (jobId: string) => api.post(`/v1/a76/pedimentos/imports/${jobId}/commit`, {}) }, // CSV import for Clases de Materiales (classes/imports) materialClassImports: { upload: (file: File, companyId: number) => { const formData = new FormData(); formData.append('file', file); return fetchApi( `/v1/a76/classes/imports/upload?company_id=${companyId}`, { method: 'POST', body: formData } ); }, status: (jobId: string) => api.get(`/v1/a76/classes/imports/${jobId}/status`), commit: (jobId: string) => api.post(`/v1/a76/classes/imports/${jobId}/commit`, {}) }, // CSV import for Vehículos / Transportes (transportation/vehicles/imports) vehicleImports: { upload: (file: File, companyId: number) => { const formData = new FormData(); formData.append('file', file); return fetchApi( `/v1/a76/transportation/vehicles/imports/upload?company_id=${companyId}`, { method: 'POST', body: formData } ); }, status: (jobId: string) => api.get(`/v1/a76/transportation/vehicles/imports/${jobId}/status`), commit: (jobId: string) => api.post(`/v1/a76/transportation/vehicles/imports/${jobId}/commit`, {}) }, // CSV import for Conductores (drivers/imports) driverImports: { upload: (file: File, companyId: number) => { const formData = new FormData(); formData.append('file', file); return fetchApi( `/v1/a76/drivers/imports/upload?company_id=${companyId}`, { method: 'POST', body: formData } ); }, status: (jobId: string) => api.get(`/v1/a76/drivers/imports/${jobId}/status`), commit: (jobId: string) => api.post(`/v1/a76/drivers/imports/${jobId}/commit`, {}) }, // CSV import for Trailers y Cajas (transportation/trailers/imports) trailerImports: { upload: (file: File, companyId: number) => { const formData = new FormData(); formData.append('file', file); return fetchApi( `/v1/a76/transportation/trailers/imports/upload?company_id=${companyId}`, { method: 'POST', body: formData } ); }, status: (jobId: string) => api.get(`/v1/a76/transportation/trailers/imports/${jobId}/status`), commit: (jobId: string) => api.post(`/v1/a76/transportation/trailers/imports/${jobId}/commit`, {}) }, // CSV import for Transportistas (transporters/imports) transporterImports: { upload: (file: File, companyId: number) => { const formData = new FormData(); formData.append('file', file); return fetchApi( `/v1/a76/transporters/imports/upload?company_id=${companyId}`, { method: 'POST', body: formData } ); }, status: (jobId: string) => api.get(`/v1/a76/transporters/imports/${jobId}/status`), commit: (jobId: string) => api.post(`/v1/a76/transporters/imports/${jobId}/commit`, {}) }, // CSV import for Números de parte (parts/imports) partNumberImports: { upload: (file: File, companyId: number) => { const formData = new FormData(); formData.append('file', file); return fetchApi( `/v1/a76/parts/imports/upload?company_id=${companyId}`, { method: 'POST', body: formData } ); }, status: (jobId: string) => api.get(`/v1/a76/parts/imports/${jobId}/status`), commit: (jobId: string) => api.post(`/v1/a76/parts/imports/${jobId}/commit`, {}) }, // CSV import for BOMs (boms/imports) bomImports: { upload: (file: File, companyId: number) => { const formData = new FormData(); formData.append('file', file); return fetchApi( `/v1/a76/boms/imports/upload?company_id=${companyId}`, { method: 'POST', body: formData } ); }, status: (jobId: string) => api.get(`/v1/a76/boms/imports/${jobId}/status`), commit: (jobId: string) => api.post(`/v1/a76/boms/imports/${jobId}/commit`, {}) }, // Generic request for custom needs (like file uploads) request: (endpoint: string, options: RequestInit = {}) => fetchApi(endpoint, options) };