chore: baseline plantilla-proyectos como base del CRM
This commit is contained in:
843
frontend/src/lib/api.ts
Normal file
843
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,843 @@
|
||||
/**
|
||||
* Cliente API para comunicación con el backend
|
||||
*/
|
||||
import { getToken } from './auth';
|
||||
import { browser } from '$app/environment';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { clearAccessTokenOnDocument, setAccessTokenOnDocument } from '$lib/access-token-cookie-browser';
|
||||
|
||||
/** Base URL absoluta para fetch; corrige `http:host` sin `//` y añade `http://` si no hay esquema. */
|
||||
function normalizeAbsoluteApiBaseUrl(raw: string): string {
|
||||
let s = (raw ?? '').trim().replace(/\/+$/, '');
|
||||
if (!s) return '';
|
||||
if (s.startsWith('http:') && !s.startsWith('http://')) {
|
||||
s = 'http://' + s.slice('http:'.length).replace(/^\/+/, '');
|
||||
}
|
||||
if (s.startsWith('https:') && !s.startsWith('https://')) {
|
||||
s = 'https://' + s.slice('https:'.length).replace(/^\/+/, '');
|
||||
}
|
||||
if (s.startsWith('/')) return s;
|
||||
if (/^https?:\/\//i.test(s)) return s;
|
||||
return `http://${s.replace(/^\/+/, '')}`;
|
||||
}
|
||||
|
||||
const API_BASE_URL = normalizeAbsoluteApiBaseUrl(String(import.meta.env.VITE_API_URL ?? ''));
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
validationErrors?: Array<{
|
||||
field: string;
|
||||
message: string;
|
||||
code?: string;
|
||||
solution?: string[];
|
||||
value?: any;
|
||||
}>;
|
||||
status: number;
|
||||
}
|
||||
|
||||
/** Reemplaza referencias técnicas `line[n]` por texto más claro para el usuario. */
|
||||
export function humanizeLineReferences(text: string): string {
|
||||
return text.replace(/\bline\[(\d+)\]/gi, 'partida $1');
|
||||
}
|
||||
|
||||
function humanizeFieldPath(field: string): string {
|
||||
const rawField = (field || '').trim();
|
||||
if (!rawField) return 'campo';
|
||||
|
||||
const lineMatch = rawField.match(/^line\[(\d+)\]\.(.+)$/i);
|
||||
const fieldPath = lineMatch?.[2] || rawField;
|
||||
const label = fieldPath
|
||||
.replace(/^body\./i, '')
|
||||
.replace(/\./g, ' → ')
|
||||
.replace(/_/g, ' ');
|
||||
|
||||
if (lineMatch) {
|
||||
return `Partida ${lineMatch[1]} - ${label}`;
|
||||
}
|
||||
|
||||
return label;
|
||||
}
|
||||
|
||||
function humanizeValidationMessage(message: string): string {
|
||||
const rawMessage = (message || '').trim();
|
||||
if (!rawMessage) return 'error de validación';
|
||||
|
||||
return rawMessage
|
||||
.replace(/\b(field required|is required)\b/gi, 'es obligatorio')
|
||||
.replace(/\b(value is not a valid decimal)\b/gi, 'debe ser un número válido')
|
||||
.replace(/\b(value is not a valid integer)\b/gi, 'debe ser un número entero válido');
|
||||
}
|
||||
|
||||
function formatValidationHint(field: string, message: string, code?: string): string {
|
||||
const fieldLabel = humanizeFieldPath(field);
|
||||
const normalizedMessage = humanizeValidationMessage(message);
|
||||
|
||||
if (code === 'REQUIRED' || code === 'REQUIRED_FIELD' || /es obligatorio|es requerido/i.test(normalizedMessage)) {
|
||||
return `Completa ${fieldLabel}.`;
|
||||
}
|
||||
|
||||
if (code === 'AMERICAN_FRACTION_NOT_FOUND') {
|
||||
return 'La fracción americana seleccionada no existe. Elige una opción del catálogo.';
|
||||
}
|
||||
|
||||
if (code === 'UNIT_OF_MEASURE_NOT_FOUND') {
|
||||
return 'La unidad de medida seleccionada no existe. Elige una opción del catálogo.';
|
||||
}
|
||||
|
||||
if (code === 'ORIGIN_COUNTRY_NOT_FOUND') {
|
||||
return 'El país de origen seleccionado no existe. Elige una opción del catálogo.';
|
||||
}
|
||||
|
||||
if (code === 'CLASS_NOT_FOUND') {
|
||||
return 'La clase seleccionada no existe. Elige una opción del catálogo.';
|
||||
}
|
||||
|
||||
if (code === 'FRACTION_TYPE_INVALID') {
|
||||
return 'Selecciona un tipo de tarifa válido.';
|
||||
}
|
||||
|
||||
return normalizedMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Título y descripción listos para toasts / alertas a partir de ApiResponse.
|
||||
* Prioriza los mensajes que ya envía el backend y evita duplicar rutas técnicas.
|
||||
*/
|
||||
export function friendlyApiErrorParts(res: ApiResponse): { title: string; description: string } {
|
||||
const validationErrors = res.validationErrors;
|
||||
if (validationErrors?.length) {
|
||||
const blocks = validationErrors.map((e) => {
|
||||
const base = formatValidationHint(e.field || '', e.message || '', e.code);
|
||||
const hints = e.solution?.filter(Boolean).length
|
||||
? '\n' + e.solution!.map((s) => `• ${humanizeLineReferences(s)}`).join('\n')
|
||||
: '';
|
||||
return base + hints;
|
||||
});
|
||||
const description = blocks.join('\n\n').trim();
|
||||
const rawTitle = (res.error || '').trim();
|
||||
const title =
|
||||
rawTitle &&
|
||||
!rawTitle.startsWith('Error de validación') &&
|
||||
rawTitle !== 'Error de validación'
|
||||
? rawTitle
|
||||
: 'Revisa los datos de la partida';
|
||||
return { title, description: description || rawTitle || 'Corrige los datos e intenta de nuevo.' };
|
||||
}
|
||||
|
||||
if (res.error) {
|
||||
const err = humanizeValidationMessage(humanizeLineReferences(res.error.trim()));
|
||||
if (err.startsWith('Error de validación:')) {
|
||||
return {
|
||||
title: 'Revisa los datos ingresados',
|
||||
description: err.replace(/^Error de validación:\s*/i, '').trim() || err
|
||||
};
|
||||
}
|
||||
return { title: 'No se pudo completar la acción', description: err };
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Error',
|
||||
description: 'Ocurrió un error inesperado. Intenta de nuevo o contacta a soporte si continúa.'
|
||||
};
|
||||
}
|
||||
|
||||
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 = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresca el token silenciosamente usando el endpoint server-side.
|
||||
*
|
||||
* El servidor lee el refresh_token desde la cookie HttpOnly,
|
||||
* llama a Keycloak, actualiza las cookies y devuelve el nuevo access_token.
|
||||
* El refresh_token NUNCA es leído por este código JavaScript.
|
||||
*/
|
||||
async function refreshToken(): Promise<string | null> {
|
||||
if (!browser) return null;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api-sveltekit/auth/silent-refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'include', // Envía cookies HttpOnly automáticamente
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('❌ [API] Silent refresh falló, status:', response.status);
|
||||
clearAccessTokenOnDocument();
|
||||
const hubBase = (import.meta.env.VITE_HUB_URL || 'https://workspace.aduanasoft.com').replace(/\/+$/, '');
|
||||
setTimeout(() => {
|
||||
window.location.href = `${hubBase}/login?return_to=${encodeURIComponent(window.location.origin + '/login?sso_verified=1')}`;
|
||||
}, 1500);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json() as { access_token?: string };
|
||||
|
||||
if (data.access_token) {
|
||||
setAccessTokenOnDocument(data.access_token);
|
||||
|
||||
// Actualizar authStore en memoria
|
||||
try {
|
||||
const { authStore } = await import('./auth');
|
||||
authStore.setToken(data.access_token);
|
||||
} catch {}
|
||||
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('❌ [API] Error en silent refresh:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye los headers de autenticación (Bearer + X-Tenant-Override SSO multi-tenant).
|
||||
* Compartido por fetchApi y fetchBlob para garantizar trato uniforme.
|
||||
*/
|
||||
function buildAuthHeaders(baseHeaders: Record<string, string> = {}): Record<string, string> {
|
||||
const headers: Record<string, string> = { ...baseHeaders };
|
||||
const token = getToken();
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// sso_tenant_pub es una cookie no-HttpOnly que el servidor setea junto con sso_tenant_id.
|
||||
if (browser) {
|
||||
const tenantPub = document.cookie
|
||||
.split('; ')
|
||||
.find((c) => c.startsWith('sso_tenant_pub='))
|
||||
?.split('=')[1];
|
||||
if (tenantPub) {
|
||||
headers['X-Tenant-Override'] = tenantPub;
|
||||
}
|
||||
|
||||
// active_system (SCAF/SCAII): cookie no-HttpOnly → header explícito para el backend.
|
||||
const activeSystem = document.cookie
|
||||
.split('; ')
|
||||
.find((c) => c.startsWith('active_system='))
|
||||
?.split('=')[1];
|
||||
if (activeSystem) {
|
||||
headers['X-Active-System'] = activeSystem;
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Realiza una petición al API con manejo automático de refresh token
|
||||
*/
|
||||
async function fetchApi<T = any>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {},
|
||||
retryCount = 0
|
||||
): Promise<ApiResponse<T>> {
|
||||
// Si ya estamos refrescando el token, esperar
|
||||
if (isRefreshing && retryCount === 0) {
|
||||
return new Promise((resolve) => {
|
||||
subscribeTokenRefresh((newToken) => {
|
||||
resolve(fetchApi<T>(endpoint, options, 1));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const token = getToken();
|
||||
|
||||
if (!token && !endpoint.includes('/auth/login')) {
|
||||
console.warn('⚠️ [API] No hay token disponible para', endpoint);
|
||||
}
|
||||
|
||||
const baseHeaders: Record<string, string> = {
|
||||
...((options.headers as Record<string, string>) || {})
|
||||
};
|
||||
|
||||
// Only set Content-Type to application/json if not already set and body is not FormData
|
||||
if (!baseHeaders['Content-Type'] && !(options.body instanceof FormData)) {
|
||||
baseHeaders['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const headers = buildAuthHeaders(baseHeaders);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include' // Importante: envía cookies con cada request
|
||||
});
|
||||
|
||||
// 403 = permisos, no autenticación: nunca intentar refresh.
|
||||
if (response.status === 403 && !endpoint.includes('/auth/refresh') && retryCount === 0) {
|
||||
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'
|
||||
});
|
||||
}
|
||||
const data = await response.json();
|
||||
return {
|
||||
error: data.detail || 'No tienes permisos para realizar esta acción',
|
||||
status: 403
|
||||
};
|
||||
}
|
||||
|
||||
// 402 = licencia inválida/expirada: no intentar refresh.
|
||||
if (response.status === 402 && !endpoint.includes('/auth/refresh') && retryCount === 0) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
return {
|
||||
error: data.message || data.detail || 'Licencia inválida o expirada',
|
||||
status: 402
|
||||
};
|
||||
}
|
||||
|
||||
// Solo 401 dispara silent refresh.
|
||||
if (response.status === 401 && !endpoint.includes('/auth/refresh') && retryCount === 0) {
|
||||
// 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<T>(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) {
|
||||
// HTTPException(detail={ message, errors }) — catálogo / CSV parity
|
||||
const det = data.detail || (typeof data.message === 'object' ? data.message : null);
|
||||
if (
|
||||
det &&
|
||||
typeof det === "object" &&
|
||||
!Array.isArray(det) &&
|
||||
Array.isArray((det as { errors?: unknown }).errors)
|
||||
) {
|
||||
const d = det as {
|
||||
message?: string;
|
||||
errors: Array<{ col?: string; msg?: string; field?: string; message?: string }>;
|
||||
};
|
||||
|
||||
// Mapping for catalog column names to DTO field names
|
||||
const colToField: Record<string, string> = {
|
||||
"CLAVE TRANSPORTISTA": "transporter_key",
|
||||
NOMBRE: "name",
|
||||
"NOMBRE CORTO": "short_name",
|
||||
RESPONSABLE: "responsible",
|
||||
RFC: "rfc",
|
||||
CALLES: "streets",
|
||||
"CODIGO POSTAL": "postal_code",
|
||||
CIUDAD: "city",
|
||||
ESTADO: "state",
|
||||
PAIS: "country",
|
||||
"CODIGO CARGADOR": "loader_code",
|
||||
"CODIGO CAAT": "caat_code",
|
||||
"CODIGO TRANS": "transport_code",
|
||||
"TIPO INTERFASE TRANS": "transport_interface_type",
|
||||
"SERVIDOR FTP": "ftp_server",
|
||||
"USUARIO FTP": "ftp_user",
|
||||
"CLAVE ACCESO FTP": "ftp_password",
|
||||
"DIRECTORIO FTP": "ftp_directory",
|
||||
// Vehicles
|
||||
CLAVE: "vehicle_key",
|
||||
"CLAVE ACE": "ace_vehicle_key", // Fallback for vehicles
|
||||
"CLAVE TRANSPORTE": "transporter_key",
|
||||
VIN: "series",
|
||||
"TIPO TRANSPORTE": "transport_type",
|
||||
"CODIGO DE ENTIDAD": "entity_code",
|
||||
TRANSPONDEDOR: "transponder_number",
|
||||
"NUMERO DOT": "dot_number",
|
||||
PLACAS: "plate_number",
|
||||
PRECINTO: "seal",
|
||||
"EMPRESA ASEGURADORA": "insurance_company_name",
|
||||
"NUM. ASEGURADORA": "insurance_number",
|
||||
"MONTO ASEGURADO": "insurance_amount",
|
||||
"FECHA DE ASEGURADORA": "insurance_date",
|
||||
// Trailers
|
||||
"NUMERO TRAILER": "trailer_number",
|
||||
"TIPO TRAILER": "trailer_type_key",
|
||||
"CODIGO ENTIDAD": "entity_code",
|
||||
"CLAVE CONTENEDOR": "container_key"
|
||||
};
|
||||
|
||||
const normalizedErrors = d.errors.map((err) => ({
|
||||
field: err.field || (err.col ? colToField[err.col] || err.col : ""),
|
||||
message: err.message || err.msg || "Error de validación"
|
||||
}));
|
||||
|
||||
return {
|
||||
error: d.message || (typeof data.message === 'string' ? data.message : 'Error de validación'),
|
||||
validationErrors: normalizedErrors,
|
||||
status: response.status
|
||||
};
|
||||
}
|
||||
// Errores de validación personalizados (con array errors en raíz)
|
||||
if (data.errors && Array.isArray(data.errors)) {
|
||||
return {
|
||||
error: data.message || 'Error de validación',
|
||||
validationErrors: data.errors as NonNullable<ApiResponse['validationErrors']>,
|
||||
status: response.status
|
||||
};
|
||||
}
|
||||
// Errores de validación de FastAPI (con detail)
|
||||
else if (data.detail) {
|
||||
let errorMessage = 'Error de validación: ';
|
||||
const vErrors: NonNullable<ApiResponse['validationErrors']> = [];
|
||||
|
||||
// FastAPI devuelve errores de validación en data.detail como array
|
||||
if (Array.isArray(data.detail)) {
|
||||
data.detail.forEach((err: any) => {
|
||||
const fieldPath = err.loc ? err.loc.filter((l: any) => l !== 'body').join('.') : 'campo';
|
||||
const msg = humanizeValidationMessage(err.msg || 'error de validación');
|
||||
|
||||
vErrors.push({
|
||||
field: err.loc ? String(err.loc[err.loc.length - 1]) : 'campo',
|
||||
message: msg
|
||||
});
|
||||
});
|
||||
|
||||
errorMessage += data.detail.map((err: any) => {
|
||||
const field = err.loc ? err.loc.join('.') : 'campo desconocido';
|
||||
return `${field}: ${err.msg}`;
|
||||
}).join(', ');
|
||||
} else if (typeof data.detail === 'string') {
|
||||
errorMessage = data.detail;
|
||||
} else {
|
||||
errorMessage += JSON.stringify(data.detail);
|
||||
}
|
||||
|
||||
return {
|
||||
error: errorMessage,
|
||||
validationErrors: vErrors.length ? vErrors : undefined,
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Opciones para subidas CSV (FormData) con progreso de red. */
|
||||
export type CsvFormDataUploadOptions = {
|
||||
onUploadProgress?: (e: { loaded: number; total: number }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* POST multipart/form-data con XMLHttpRequest para exponer progreso de subida.
|
||||
* Misma semántica de auth/401/403/422 que fetchApi.
|
||||
*/
|
||||
async function fetchApiFormDataPost<T = any>(
|
||||
endpoint: string,
|
||||
formData: FormData,
|
||||
opts: CsvFormDataUploadOptions & { retryCount?: number } = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
const retryCount = opts.retryCount ?? 0;
|
||||
|
||||
if (isRefreshing && retryCount === 0) {
|
||||
return new Promise((resolve) => {
|
||||
subscribeTokenRefresh(() => {
|
||||
resolve(fetchApiFormDataPost<T>(endpoint, formData, { ...opts, retryCount: 1 }));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const token = getToken();
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', `${API_BASE_URL}${endpoint}`);
|
||||
xhr.withCredentials = true;
|
||||
if (token) {
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
xhr.upload.onprogress = (ev) => {
|
||||
if (!opts.onUploadProgress) return;
|
||||
if (ev.lengthComputable) {
|
||||
opts.onUploadProgress({ loaded: ev.loaded, total: ev.total });
|
||||
} else {
|
||||
opts.onUploadProgress({ loaded: ev.loaded, total: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
void (async () => {
|
||||
const status = xhr.status;
|
||||
let data: any = null;
|
||||
if (xhr.responseText) {
|
||||
try {
|
||||
data = JSON.parse(xhr.responseText) as any;
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
}
|
||||
|
||||
if ((status === 401 || status === 403) && !endpoint.includes('/auth/refresh') && retryCount === 0) {
|
||||
if (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'
|
||||
});
|
||||
}
|
||||
resolve({
|
||||
error: data?.detail || 'No tienes permisos para realizar esta acción',
|
||||
status: 403
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
try {
|
||||
const newToken = await refreshToken();
|
||||
if (newToken) {
|
||||
onTokenRefreshed(newToken);
|
||||
isRefreshing = false;
|
||||
resolve(await fetchApiFormDataPost<T>(endpoint, formData, { ...opts, retryCount: 1 }));
|
||||
} else {
|
||||
console.error('❌ [API] No se pudo refrescar el token');
|
||||
isRefreshing = false;
|
||||
resolve({
|
||||
error: 'Sesión expirada. Por favor, inicia sesión nuevamente.',
|
||||
status: 401
|
||||
});
|
||||
}
|
||||
} catch (refreshError) {
|
||||
console.error('❌ [API] Error al refrescar:', refreshError);
|
||||
isRefreshing = false;
|
||||
resolve({
|
||||
error: 'Error al refrescar la sesión',
|
||||
status: 401
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 204) {
|
||||
resolve({
|
||||
data: null as T,
|
||||
status
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 0) {
|
||||
resolve({
|
||||
error: 'Error de conexión con el servidor',
|
||||
status: 0
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (status < 200 || status >= 300) {
|
||||
if (status === 422 && data) {
|
||||
if (data.errors && Array.isArray(data.errors)) {
|
||||
resolve({
|
||||
error: data.message || 'Error de validación',
|
||||
validationErrors: data.errors as NonNullable<ApiResponse['validationErrors']>,
|
||||
status: 422
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (data.detail) {
|
||||
let errorMessage = 'Error de validación: ';
|
||||
if (Array.isArray(data.detail)) {
|
||||
const errors = data.detail
|
||||
.map((err: any) => {
|
||||
const field = err.loc ? err.loc.filter((loc: string) => loc !== 'body').join('.') : 'campo desconocido';
|
||||
return `${humanizeFieldPath(field)}: ${humanizeValidationMessage(err.msg || 'error de validación')}`;
|
||||
})
|
||||
.join(', ');
|
||||
errorMessage += errors;
|
||||
} else if (typeof data.detail === 'string') {
|
||||
errorMessage = data.detail;
|
||||
} else {
|
||||
errorMessage += JSON.stringify(data.detail);
|
||||
}
|
||||
resolve({
|
||||
error: errorMessage,
|
||||
status: 422
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
resolve({
|
||||
error:
|
||||
data?.message ||
|
||||
(typeof data?.detail === 'string' ? data.detail : JSON.stringify(data?.detail)) ||
|
||||
'Error en la petición',
|
||||
status
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (data === null && xhr.responseText) {
|
||||
resolve({
|
||||
error: 'Respuesta inválida del servidor',
|
||||
status
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
data,
|
||||
status
|
||||
});
|
||||
})();
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
resolve({
|
||||
error: 'Error de conexión con el servidor',
|
||||
status: 0
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
xhr.send(formData);
|
||||
} catch (error) {
|
||||
console.error(`❌ [API] Error al enviar ${endpoint}:`, error);
|
||||
resolve({
|
||||
error: 'Error de conexión con el servidor',
|
||||
status: 0
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convierte cuerpos de error (JSON o texto) en un mensaje legible para toasts/UX.
|
||||
* Evita mostrar JSON crudo p. ej. `{"error":"HTTP_ERROR","message":"..."}`.
|
||||
*/
|
||||
function messageFromBlobErrorResponse(text: string, status: number): string {
|
||||
const raw = (text || '').trim();
|
||||
if (!raw) {
|
||||
return status === 404
|
||||
? 'No se encontró el recurso. Prueba otro rango o vuelve a intentar.'
|
||||
: `Error ${status} al descargar el archivo.`;
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(raw) as Record<string, unknown>;
|
||||
if (typeof data.message === 'string' && data.message.trim()) {
|
||||
return data.message.trim();
|
||||
}
|
||||
const d = data.detail;
|
||||
if (typeof d === 'string' && d.trim()) {
|
||||
return d.trim();
|
||||
}
|
||||
if (Array.isArray(d) && d[0] && typeof (d[0] as { msg?: string }).msg === 'string') {
|
||||
return String((d[0] as { msg: string }).msg).trim();
|
||||
}
|
||||
} catch {
|
||||
// no es JSON: usar texto plano si es corto y legible
|
||||
}
|
||||
if (raw.length < 500 && !raw.startsWith('{')) {
|
||||
return raw;
|
||||
}
|
||||
if (raw.startsWith('{')) {
|
||||
return status === 404
|
||||
? 'No se encontró información para exportar. Prueba otras fechas o amplía el rango.'
|
||||
: `Error ${status} al descargar el archivo.`;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
async function fetchBlob(
|
||||
endpoint: string,
|
||||
options: RequestInit = {},
|
||||
retryCount = 0
|
||||
): Promise<Blob> {
|
||||
// Si ya estamos refrescando el token, esperar a que termine antes de pegar.
|
||||
if (isRefreshing && retryCount === 0) {
|
||||
return new Promise((resolve, reject) => {
|
||||
subscribeTokenRefresh(() => {
|
||||
fetchBlob(endpoint, options, 1).then(resolve).catch(reject);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const headers = buildAuthHeaders((options.headers as Record<string, string>) || {});
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
// 401: intentar silent refresh y reintentar una vez (mismo flujo que fetchApi).
|
||||
if (response.status === 401 && !endpoint.includes('/auth/refresh') && retryCount === 0) {
|
||||
isRefreshing = true;
|
||||
try {
|
||||
const newToken = await refreshToken();
|
||||
if (newToken) {
|
||||
onTokenRefreshed(newToken);
|
||||
isRefreshing = false;
|
||||
return await fetchBlob(endpoint, options, 1);
|
||||
}
|
||||
isRefreshing = false;
|
||||
throw new Error('Sesión expirada. Por favor, inicia sesión nuevamente.');
|
||||
} catch (refreshError) {
|
||||
isRefreshing = false;
|
||||
if (refreshError instanceof Error) throw refreshError;
|
||||
throw new Error('Error al refrescar la sesión');
|
||||
}
|
||||
}
|
||||
|
||||
// 403: notificar permisos de manera consistente con fetchApi.
|
||||
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'
|
||||
});
|
||||
}
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(messageFromBlobErrorResponse(text, response.status));
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(messageFromBlobErrorResponse(text, response.status));
|
||||
}
|
||||
return await response.blob();
|
||||
}
|
||||
|
||||
// Métodos HTTP
|
||||
export const api = {
|
||||
get: <T = any>(endpoint: string) => fetchApi<T>(endpoint, { method: 'GET' }),
|
||||
getBlob: (endpoint: string) => fetchBlob(endpoint, { method: 'GET' }),
|
||||
postBlob: (endpoint: string, body: any) =>
|
||||
fetchBlob(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
}),
|
||||
|
||||
post: <T = any>(endpoint: string, body: any, options: RequestInit = {}) =>
|
||||
fetchApi<T>(endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
...options
|
||||
}),
|
||||
|
||||
put: <T = any>(endpoint: string, body: any, options: RequestInit = {}) =>
|
||||
fetchApi<T>(endpoint, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
...options
|
||||
}),
|
||||
|
||||
patch: <T = any>(endpoint: string, body: any, options: RequestInit = {}) =>
|
||||
fetchApi<T>(endpoint, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
...options
|
||||
}),
|
||||
|
||||
delete: <T = any>(endpoint: string, options: RequestInit = {}) => fetchApi<T>(endpoint, { method: 'DELETE', ...options }),
|
||||
|
||||
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'),
|
||||
register: (data: {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
tenant_slug: string;
|
||||
invite_token?: string;
|
||||
}) => api.post('/v1/auth/register', data),
|
||||
},
|
||||
|
||||
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}/`)
|
||||
},
|
||||
|
||||
// Agrega aquí los endpoints específicos de tu proyecto.
|
||||
|
||||
// Implementa aquí tus endpoints de importación CSV si los necesitas.
|
||||
|
||||
// Generic request for custom needs (like file uploads)
|
||||
request: <T = any>(endpoint: string, options: RequestInit = {}) => fetchApi<T>(endpoint, options)
|
||||
};
|
||||
Reference in New Issue
Block a user