diff --git a/CAMBIOS_v1.10.0.md b/CAMBIOS_v1.10.0.md deleted file mode 100644 index 0bbfc55..0000000 --- a/CAMBIOS_v1.10.0.md +++ /dev/null @@ -1,726 +0,0 @@ -# ServiceManagerWeb — Versión 1.10.0 -## Reporte Técnico de Cambios y Mejoras - ---- - -**Proyecto:** ServiceManagerWeb – Mesa de Ayuda B2B Multi-tenant -**Versión:** 1.10.0 -**Versión base:** 1.8.0 (commit `e644039`) / 1.9.0 (commit `16d795e`) -**Fecha:** 19 de Febrero de 2026 -**Estado:** Sistema Funcional — Producción MVP -**Empresa:** Aduanasoft -**Autor:** Equipo de Desarrollo - ---- - -## Resumen Ejecutivo - -La versión 1.10.0 representa una fase de refactorización técnica profunda, optimización de rendimiento, y mejoras significativas en la interfaz de usuario. Los cambios abarcan el ciclo completo del sistema: backend (Python/FastAPI), frontend interno (SvelteKit), frontend cliente (SvelteKit) y la capa de infraestructura (Docker). - -### Métricas Globales de esta Versión - -| Indicador | Valor | -|---|---| -| Archivos modificados | 31 archivos | -| Líneas añadidas (total) | ~3,250 líneas | -| Líneas eliminadas (total) | ~3,440 líneas | -| Reducción neta de código | ~190 líneas (refactorización limpia) | -| Archivos nuevos creados | 14 archivos | -| Archivos eliminados | 3 scripts de prueba temporales | -| Scripts reorganizados | 3 (movidos a `backend/scripts/`) | - ---- - -## 1. Backend — Python / FastAPI - -### 1.1 `backend/app/api/v1/endpoints/audit.py` -**Cambios:** +166 líneas añadidas / −1,119 líneas eliminadas -**Balance neto:** −953 líneas (reducción del 74% del archivo) - -#### Problema detectado -El archivo `audit.py` tenía 1,285 líneas en la versión 1.8.0. Toda la lógica de detección de amenazas, análisis de seguridad y transformación de datos estaba inline dentro de las funciones de cada endpoint, generando duplicación masiva y dificultando el mantenimiento. - -#### Cambios realizados - -**a) Extracción de lógica a módulo auxiliar** -Se creó el archivo `backend/app/api/v1/audit_helpers.py` (nuevo, ver sección 1.8) con las siguientes funciones extraídas del archivo original: - -```python -# ANTES — en audit.py líneas 420-580 (inline) -# Toda la lógica de detección de amenazas vivía dentro de la función -# get_security_analysis() sin separación alguna - -# DESPUÉS — importado desde audit_helpers.py -from app.api.v1.audit_helpers import ( - audit_log_to_dict, - apply_tenant_filter, - get_count_stat, - get_top_items, - detect_mass_deletions, - detect_brute_force, - detect_privilege_escalation -) -``` - -**b) Docstrings compactados** -Los docstrings multilínea extensos se compactaron a una sola línea donde el nombre era autoexplicativo: - -```python -# ANTES (líneas 1-7 del archivo original) -""" -Audit Endpoints - ServiceManagerWeb - -Endpoints para consulta de logs de auditoría. -Solo accesible por roles: ADMIN, SUPPORT_MANAGER, AUDITOR -""" - -# DESPUÉS (línea 1) -"""Audit Endpoints - ServiceManagerWeb""" -``` - -**c) Firma de función require_auditor_role refactorizada** -```python -# ANTES (líneas 32-47) — 16 líneas -def require_auditor_role(current_user: User = Depends(get_current_user)) -> User: - """ - Dependency que verifica que el usuario tenga rol de auditor. - Solo ADMIN, SUPPORT_MANAGER y AUDITOR pueden ver logs de auditoría. - """ - allowed_roles = [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR] - if current_user.role not in allowed_roles: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Solo usuarios con rol ADMIN, SUPPORT_MANAGER o AUDITOR..." - ) - return current_user - -# DESPUÉS (líneas 20-24) — 5 líneas -def require_auditor_role(current_user: User = Depends(get_current_user)) -> User: - """Verifica que el usuario tenga rol de auditor""" - if current_user.role not in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR]: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, - detail="Solo usuarios con rol ADMIN, SUPPORT_MANAGER o AUDITOR pueden acceder") - return current_user -``` - -**d) Firma del endpoint `get_audit_logs` compactada** -```python -# ANTES (líneas 49-72) — 24 líneas de parámetros separados - -# DESPUÉS — parámetros agrupados en 6 líneas -async def get_audit_logs( - page: int = Query(default=1, ge=1), - per_page: int = Query(default=50, ge=1, le=100), - user_id: Optional[uuid.UUID] = Query(None), - action: Optional[str] = Query(None), - ... -``` - -**e) Corrección del schema `SecurityAnalysisResponse`** -Se añadieron todos los campos requeridos que causaban error 500 al serializar la respuesta. Los campos faltantes eran: -- `analysis_period_hours` -- `total_threats_detected` -- `suspicious_ips_count` -- `critical_actions_count` - ---- - -### 1.2 `backend/app/api/v1/endpoints/auth.py` -**Cambios:** +369 líneas añadidas / −40 líneas eliminadas -**Balance neto:** +329 líneas - -#### Cambios realizados -Se amplió la cobertura de autenticación con: -- Manejo robusto de tokens de refresco -- Validación mejorada de credenciales con mensajes de error específicos -- Soporte para recuperación de contraseña por email -- Integración con el servicio de email (`app/core/email.py`) -- Logging estructurado con `structlog` en todos los endpoints críticos - ---- - -### 1.3 `backend/app/api/v1/endpoints/tickets.py` -**Cambios:** +217 líneas añadidas / −872 líneas eliminadas -**Balance neto:** −655 líneas (reducción del 60%) - -#### Problema detectado -Igual que `audit.py`, el archivo de tickets tenía lógica de negocio repetida y funciones helper inline. - -#### Cambios realizados - -**a) Extracción a `backend/app/api/v1/helpers.py`** -Se creó un módulo auxiliar general (nuevo, ver sección 1.9) con funciones reutilizables como: -- `build_ticket_query()` — construye queries SQLAlchemy con filtros dinámicos -- `paginate_query()` — paginación genérica reutilizable -- `format_ticket_response()` — serialización consistente - -**b) Corrección del error de `sla_breached`** -```python -# ANTES — causaba AttributeError / 500 en producción -response.sla_breached = ticket.sla_breached # ← propiedad no existía - -# DESPUÉS — removido, calculado dinámicamente -# sla_breached se calcula desde sla_deadline vs datetime.utcnow() -``` - ---- - -### 1.4 `backend/app/api/schemas/__init__.py` -**Cambios:** +54 líneas añadidas / −4 líneas eliminadas - -Se reorganizaron los schemas en módulos separados: - -``` -# ANTES — un archivo monolítico schemas/__init__.py con todo - -# DESPUÉS — módulos independientes por dominio: -backend/app/api/schemas/ -├── __init__.py (re-exports, 58 líneas totales) -├── auth.py (NUEVO — schemas de autenticación) -├── category.py (NUEVO — schemas de categorías) -├── system.py (NUEVO — schemas de sistemas) -├── tenant.py (NUEVO — schemas de tenants) -├── ticket.py (NUEVO — schemas de tickets) -└── user.py (NUEVO — schemas de usuarios) -``` - ---- - -### 1.5 `backend/app/middleware/tenant.py` -**Cambios:** +103 líneas añadidas / −37 líneas eliminadas -**Balance neto:** +66 líneas - -#### Cambios realizados -- Mejora del middleware de contexto multi-tenant con mejor manejo de headers `X-Tenant-ID` -- Logging detallado de tenant context para debugging -- Validación más robusta del tenant activo -- Soporte para tenant bypass en endpoints de health/docs - ---- - -### 1.6 `backend/app/main.py` -**Cambios:** +7 líneas añadidas / −0 líneas eliminadas - -```python -# AÑADIDO — registro de nuevos routers -from app.api.v1.endpoints import profile # router de perfil de usuario -app.include_router(profile.router, prefix="/api/v1/profile", tags=["profile"]) -``` - ---- - -### 1.7 `backend/app/core/database.py` -**Cambios:** +3 líneas / −2 líneas - -```python -# ANTES -engine = create_async_engine(settings.DATABASE_URL, echo=False) - -# DESPUÉS — pool tuning para mayor concurrencia -engine = create_async_engine( - settings.DATABASE_URL, - pool_pre_ping=True, - pool_recycle=300 -) -``` - ---- - -### 1.8 `backend/app/api/v1/audit_helpers.py` (ARCHIVO NUEVO) -**Líneas:** ~120 líneas - -Módulo auxiliar extraído de `audit.py`. Contiene: - -| Función | Descripción | -|---|---| -| `audit_log_to_dict(log)` | Serializa un AuditLog a dict | -| `apply_tenant_filter(query, user, tenant, all_tenants)` | Aplica filtro multi-tenant | -| `get_count_stat(db, model, filters)` | Cuenta registros con filtros | -| `get_top_items(db, field, limit)` | Top N elementos de un campo | -| `detect_mass_deletions(logs)` | Detecta patrones de eliminación masiva | -| `detect_brute_force(logs)` | Detecta intentos de brute force | -| `detect_privilege_escalation(logs)` | Detecta escalada de privilegios | - ---- - -### 1.9 `backend/app/api/v1/helpers.py` (ARCHIVO NUEVO) -**Líneas:** ~80 líneas - -Helper general para tickets y recursos compartidos. - ---- - -### 1.10 `backend/app/core/email.py` (ARCHIVO NUEVO) -Módulo de envío de email para notificaciones y recuperación de contraseña, integrado con Celery workers. - ---- - -### 1.11 `backend/app/core/cache.py` (ARCHIVO NUEVO) -Módulo de caché con Redis: -- `cache_get(key)` / `cache_set(key, value, ttl)` -- Decorador `@cached(ttl=300)` para funciones async -- Invalidación por patrón de claves - ---- - -### 1.12 `backend/migrations/versions/a1b2c3d4e5f6_add_audit_logs_table.py` -**Cambios:** +57 líneas / −1 línea - -Migration completada: se añadió la tabla `security_incidents` con campos: -- `id UUID PRIMARY KEY` -- `title VARCHAR(255)` -- `description TEXT` -- `severity ENUM(low, medium, high, critical)` -- `status ENUM(active, investigating, resolved)` -- `tenant_id UUID FK` -- `created_at TIMESTAMP` -- `updated_at TIMESTAMP` - ---- - -### 1.13 `backend/tests/conftest.py` -**Cambios:** +151 líneas / −13 líneas - -Se amplió el fixture base para pruebas de integración: -- Fixtures para multi-tenant testing -- Fixtures para usuario con rol AUDITOR -- Data factories para tickets, incidentes y audit logs - ---- - -### 1.14 Reorganización de Scripts -``` -# ANTES — en raíz de backend/ -backend/check_tenants.py -backend/create_test_user.py -backend/set_test_password.py - -# DESPUÉS — carpeta dedicada -backend/scripts/check_tenants.py -backend/scripts/create_test_user.py -backend/scripts/set_test_password.py -``` - ---- - -### 1.15 Nuevos Tests Unitarios (archivos nuevos) -``` -backend/tests/unit/ -├── test_audit_service.py (cobertura del AuditService) -├── test_config.py (validación de settings) -├── test_middleware.py (pruebas del middleware tenant) -├── test_schemas.py (validación de schemas Pydantic) -└── test_security.py (pruebas de JWT y hashing) -``` - ---- - -## 2. Frontend Interno — SvelteKit / TypeScript - -### 2.1 `frontend-internal/src/routes/audit/+page.svelte` -**Cambios:** +1,253 líneas añadidas / −554 líneas eliminadas -**Líneas totales finales:** 2,050 líneas - -Este es el archivo con más cambios de toda la versión. Se realizó una refactorización completa de la página de auditoría. - -#### 2.1.1 Tipado TypeScript — Corrección de Warnings - -```typescript -// ANTES (líneas 9-17) — tipos implícitos, generaba warnings -let logs = []; -let stats = null; -let users = []; -let incidents = []; -let securityAnalysis = null; -let selectedLog = null; -let selectedIncident = null; - -// DESPUÉS — tipos explícitos -let logs: any[] = []; -let stats: any = null; -let users: any[] = []; -let incidents: any[] = []; -let securityAnalysis: any = null; -let selectedLog: any = null; -let selectedIncident: any = null; -``` - -#### 2.1.2 Responses de API tipadas - -```typescript -// ANTES — response sin tipo, causaba errores -const response = await api.get('/audit/security/incidents', params); -incidents = response.incidents || []; // TS error: 'response' is of type 'unknown' - -// DESPUÉS — response con tipo explícito -const response: any = await api.get('/audit/security/incidents', params); -incidents = response.incidents || []; -``` - -#### 2.1.3 Catch blocks tipados (5 bloques corregidos) - -```typescript -// ANTES — 5 bloques con e sin tipo -} catch (e) { - console.error('Error:', e); - -// DESPUÉS — todos tipados -} catch (e: any) { - console.error('Error:', e); -``` - -#### 2.1.4 Botones de período refactorizados con array tipado - -```typescript -// ANTES — 5 bloques - - - - - -// DESPUÉS — array con tipo estricto + loop, ~15 líneas -const periodButtons: Array<{ - id: 'today' | 'yesterday' | 'last7days' | 'last30days' | 'custom', - label: string, - icon?: boolean -}> = [ - { id: 'today', label: 'Hoy' }, - { id: 'yesterday', label: 'Ayer' }, - { id: 'last7days', label: 'Últimos 7 días' }, - { id: 'last30days', label: 'Últimos 30 días' }, - { id: 'custom', label: 'Personalizado', icon: true } -]; - -{#each periodButtons as btn} - -{/each} -``` - -#### 2.1.5 Tarjetas estadísticas refactorizadas con array reactivo - -```typescript -// ANTES — 4 bloques
idénticos con ~25 líneas cada uno (~100 líneas totales) -// Total del Acciones — bloque completo -
-
Total de Registros
-
{stats.total_actions.toLocaleString()}
- ... -
-// Acciones Hoy — bloque completo (repetido) -// Esta Semana — bloque completo (repetido) -// Incidentes Críticos — bloque completo (repetido) - -// DESPUÉS — array reactivo + loop, ~40 líneas totales -$: statsCards = [ - { label: 'Total de Registros', value: stats?.total_actions, icon: 'clipboard', color: 'gray', desc: '...' }, - { label: 'Actividad Hoy', value: stats?.actions_today, icon: 'zap', color: 'blue', desc: '...' }, - { label: 'Esta Semana', value: stats?.actions_this_week, icon: 'calendar', color: 'indigo', desc: '...' }, - { label: 'Incidentes Críticos', value: stats?.critical_actions_today || 0, icon: 'alert', color: 'red', desc: '...', action: true } -]; - -{#each statsCards as card} -
- ... -
-{/each} -``` - -#### 2.1.6 Sección de Análisis de Seguridad reemplazada por enlace - -```svelte - - - - - - - - -``` - -Esta decisión separa la responsabilidad: la página `/audit` muestra el **resumen de actividad**, mientras que `/audit/security` muestra el **análisis detallado de amenazas**. - -#### 2.1.7 Mejoras de espaciado y layout - -- **Contenedor principal:** `px-4 sm:px-6 lg:px-8 py-8` — márgenes responsivos -- **Encabezado de página:** añadido con `h1` + descripción -- **Separación entre secciones:** `mb-8` uniforme (antes `mb-6` variable) -- **Etiquetas de sección:** añadidos `

` para "Resumen de Actividad", "Incidentes de Seguridad", "Registros de Auditoría" -- **Tarjetas con headers descriptivos:** añadidos `

` en toggles y controles - ---- - -### 2.2 `frontend-internal/src/routes/tickets/+page.svelte` -**Cambios:** +731 líneas añadidas / −338 líneas eliminadas - -#### Cambios realizados -- Corrección de ortografía en 11 etiquetas de texto (ej: "priorida" → "prioridad") -- Mejora del filtro de estado y prioridad con selects correctamente bound a variables reactivas -- Vista de tabla compacta con rows más ajustados (`py-2` en lugar de `py-4`) -- Indicadores de color para prioridad (urgente=rojo, alto=naranja, medio=amarillo, bajo=azul) -- Modal de detalle de ticket con información de SLA sin acceder a propiedades no existentes - ---- - -### 2.3 `frontend-internal/src/lib/components/Sidebar.svelte` -**Cambios:** +17 líneas / −6 líneas - -```svelte - - - - - Reportes - -``` - ---- - -### 2.4 `frontend-internal/vite.config.js` -**Cambios:** +20 líneas / −16 líneas - -```javascript -// ANTES — proxy incorrecto durante desarrollo -proxy: { - '/api': 'http://localhost:8000' // ← fallaba dentro de Docker -} - -// DESPUÉS — proxy correcto para red Docker -proxy: { - '/api': { - target: 'http://backend:8000', - changeOrigin: true, - rewrite: (path) => path.replace(/^\/api/, '') - } -} -``` - ---- - -### 2.5 Nuevos Utilitarios Frontend (archivos nuevos) - -#### `frontend-internal/src/lib/utils/colorUtils.ts` (NUEVO, 74 líneas) - -```typescript -// Centraliza todos los mapas de colores del sistema -type ColorType = 'severity' | 'status' | 'action' | 'priority'; - -export function getColorClass(value: string, type: ColorType = 'status'): string -export function getStatusIcon(status: string): string -``` - -#### `frontend-internal/src/lib/utils/dateFormats.ts` (NUEVO, 78 líneas) - -```typescript -// Centraliza el formateo de fechas -export function formatDate(dateString: string, format: DateFormat = 'full'): string -export function getRelativeTime(dateString: string): string -export function getDateRangeForPeriod(period: string, from?: string, to?: string): DateRange -``` - ---- - -## 3. Frontend Cliente — SvelteKit / TypeScript - -### 3.1 `frontend-client/src/routes/profile/+page.svelte` -**Cambios:** +194 líneas / −56 líneas - -Nueva funcionalidad de perfil de usuario con: -- Visualización de datos personales del cliente -- Formulario de edición de nombre y contacto -- Cambio de contraseña con validación de fortaleza -- Indicador visual del tipo de cuenta - ---- - -### 3.2 `frontend-client/vite.config.js` -**Cambios:** +4 líneas / −0 líneas - -```javascript -// AÑADIDO — proxy para comunicación con backend -server: { - proxy: { - '/api': { target: 'http://backend:8000', ... } - } -} -``` - ---- - -### 3.3 Nuevas Rutas Frontend Cliente (archivos nuevos) - -``` -frontend-client/src/routes/ -├── forgot-password/ (NUEVO — flujo de recuperación de contraseña) -├── reset-password/ (NUEVO — formulario de nueva contraseña con token) -└── organization/ (NUEVO — vista de datos de la organización del cliente) -``` - ---- - -### 3.4 `frontend-client/src/lib/components/Header.svelte` -**Cambios:** +8 líneas / −2 líneas - -- Añadido enlace a perfil de usuario en el dropdown del header -- Enlace a "Mi Organización" visible para `CLIENT_ADMIN` - ---- - -## 4. Infraestructura y DevOps - -### 4.1 `docker/Dockerfile.backend` -**Cambios:** +3 líneas / −1 línea - -```dockerfile -# AÑADIDO — dependencias del sistema para compilar bcrypt -RUN apt-get install -y build-essential libffi-dev -``` - ---- - -### 4.2 `frontend-internal/package.json` -**Cambios:** +1 línea / −1 línea - -```json -// ACTUALIZADO — versión de @sveltejs/kit para fix de routing -"@sveltejs/kit": "^1.27.0" // antes ^1.6.0 -``` - ---- - -## 5. Archivos Eliminados - -| Archivo | Razón | -|---|---| -| `test_frontend_integration.ps1` (174 líneas) | Script de prueba temporal — funcionalidad absorbida por suite de tests | -| `test_manual.ps1` (142 líneas) | Script de prueba manual obsoleto | -| `test_tenant_update.ps1` (101 líneas) | Script específico para prueba puntual, ya no necesario | - -**Total eliminado:** 417 líneas de código temporal/obsoleto - ---- - -## 6. Nuevos Archivos Creados - -| Archivo | Líneas | Propósito | -|---|---|---| -| `backend/app/api/v1/audit_helpers.py` | ~120 | Helpers de auditoría extraídos de audit.py | -| `backend/app/api/v1/helpers.py` | ~80 | Helpers generales de tickets y queries | -| `backend/app/api/schemas/auth.py` | ~60 | Schemas Pydantic para autenticación | -| `backend/app/api/schemas/category.py` | ~30 | Schemas de categorías | -| `backend/app/api/schemas/system.py` | ~30 | Schemas de sistemas | -| `backend/app/api/schemas/tenant.py` | ~40 | Schemas de tenants | -| `backend/app/api/schemas/ticket.py` | ~80 | Schemas de tickets | -| `backend/app/api/schemas/user.py` | ~50 | Schemas de usuarios | -| `backend/app/core/email.py` | ~90 | Servicio de envío de email | -| `backend/app/core/cache.py` | ~70 | Módulo de caché Redis | -| `backend/tests/unit/test_audit_service.py` | ~100 | Tests del servicio de auditoría | -| `backend/tests/unit/test_config.py` | ~50 | Tests de configuración | -| `backend/tests/unit/test_middleware.py` | ~80 | Tests del middleware tenant | -| `backend/tests/unit/test_schemas.py` | ~70 | Tests de validación de schemas | -| `backend/tests/unit/test_security.py` | ~60 | Tests de seguridad JWT | -| `frontend-internal/src/lib/utils/colorUtils.ts` | 74 | Centralización de colores | -| `frontend-internal/src/lib/utils/dateFormats.ts` | 78 | Centralización de formatos de fecha | -| `frontend-client/src/routes/forgot-password/` | ~80 | Flujo de recuperación de contraseña | -| `frontend-client/src/routes/reset-password/` | ~90 | Formulario reset con token | -| `frontend-client/src/routes/organization/` | ~120 | Vista de organización del cliente | -| `frontend-internal/src/routes/profile/` | ~150 | Perfil del usuario interno | -| `OPTIMIZACIONES_RENDIMIENTO.md` | 344 | Guía técnica de optimizaciones futuras | - ---- - -## 7. Correcciones de Bugs - -### Bug #1 — Error 500 en `/audit/security/analysis` -**Causa:** El schema `SecurityAnalysisResponse` de Pydantic no incluía los campos `analysis_period_hours`, `total_threats_detected`, `suspicious_ips_count`, `critical_actions_count`. Al intentar serializar la respuesta, Pydantic lanzaba `ValidationError`. -**Archivo:** `backend/app/api/v1/endpoints/audit.py` -**Fix:** Se añadieron los campos faltantes al schema de respuesta en `backend/app/api/schemas/__init__.py`. - -### Bug #2 — Error 500 en detalle de ticket (`/tickets/{id}`) -**Causa:** El endpoint accedía a `ticket.sla_breached` que no es una columna de la tabla, sino un cálculo derivado. -**Archivo:** `backend/app/api/v1/endpoints/tickets.py` -**Fix:** Se eliminó la referencia a `ticket.sla_breached` y se calcula dinámicamente: `sla_breached = ticket.sla_deadline < datetime.utcnow() if ticket.sla_deadline else False` - -### Bug #3 — Proxy 404 en desarrollo con Docker -**Causa:** `vite.config.js` apuntaba a `localhost:8000` en lugar del hostname Docker `backend:8000`. -**Archivos:** `frontend-internal/vite.config.js`, `frontend-client/vite.config.js` -**Fix:** Se actualizó el target del proxy a `http://backend:8000` con `changeOrigin: true`. - -### Bug #4 — Filtros de tickets no aplicaban -**Causa:** Los parámetros `status` y `priority` del frontend construían query strings con nombres incorrectos (`status_filter` en vez de `status`). -**Archivo:** `frontend-internal/src/routes/tickets/+page.svelte` -**Fix:** Corregidos los nombres de parámetros para coincidir con los Query params del backend. - -### Bug #5 — Archivos con prefijo `+` causaban error de SvelteKit -**Causa:** Durante el desarrollo se crearon archivos de respaldo con nombres `+page.svelte.backup` y `+page.svelte.tmp`. SvelteKit interpreta cualquier archivo con `+` como una ruta especial. -**Fix:** Se eliminaron todos los archivos de respaldo con formato `+*.tmp`. - ---- - -## 8. Correcciones Ortográficas (frontend-internal) - -En `frontend-internal/src/routes/tickets/+page.svelte` se corrigieron 11 errores ortográficos: - -| Línea aprox. | Antes | Después | -|---|---|---| -| ~145 | `priorida` | `prioridad` | -| ~189 | `Estad` | `Estado` | -| ~234 | `Accionnes` | `Acciones` | -| ~267 | `Assigado` | `Asignado` | -| ~310 | `Fecah` | `Fecha` | -| ~345 | `Prioiridad` | `Prioridad` | -| ~389 | `Ticktes` | `Tickets` | -| ~412 | `Resolucion` | `Resolución` | -| ~456 | `Sataus` | `Status` | -| ~478 | `Critcio` | `Crítico` | -| ~501 | `Asignar` → etiqueta incorrecta | Texto corregido contextualmente | - ---- - -## 9. Notas de Migración - -Para actualizar de v1.8.0 / v1.9.0 a v1.10.0: - -```bash -# 1. Actualizar código -git pull origin main -git checkout version-1.10.0 - -# 2. Aplicar migraciones de base de datos -docker-compose exec backend alembic upgrade head - -# 3. Reconstruir imágenes (cambios en Dockerfile) -docker-compose build --no-cache backend - -# 4. Reiniciar todos los servicios -docker-compose up -d - -# 5. Verificar salud -curl http://localhost:8000/health -``` - ---- - -## 10. Estado del Sistema tras v1.10.0 - -| Componente | Estado | Notas | -|---|---|---| -| Backend FastAPI | ✅ Funcional | 0 errores 500 en endpoints principales | -| Frontend Interno | ✅ Funcional | Proxy Docker correcto | -| Frontend Cliente | ✅ Funcional | Nuevas rutas de perfil y organización | -| Base de Datos | ✅ Migrada | Tabla security_incidents disponible | -| Celery Workers | ✅ Funcional | Integrado con email service | -| Redis Cache | ✅ Funcional | Módulo cache.py implementado | -| Tests Unitarios | ✅ Nuevos | 5 nuevos archivos de tests | -| Docker Compose | ✅ Funcional | Todos los servicios healthy | - ---- - -*Documento generado: 19 de Febrero de 2026* -*Versión del documento: 1.0* -*ServiceManagerWeb — Aduanasoft* diff --git a/CAMBIOS_v1.8.0.md b/CAMBIOS_v1.8.0.md deleted file mode 100644 index c662dc2..0000000 --- a/CAMBIOS_v1.8.0.md +++ /dev/null @@ -1,847 +0,0 @@ -# ServiceManagerWeb - Versión 1.8.0 -## Reporte Técnico de Cambios y Mejoras - ---- - -**Proyecto:** ServiceManagerWeb - Mesa de Ayuda B2B Multi-tenant -**Versión:** 1.8.0 -**Fecha:** 17 de Febrero de 2026 -**Estado:** Sistema Funcional para Producción MVP -**Empresa:** Aduanasoft - ---- - -## 📋 Resumen Ejecutivo - -La versión 1.8.0 representa un hito importante en el desarrollo del sistema, consolidando la funcionalidad completa del módulo de tickets con un sistema de filtros operativo, optimizaciones significativas en la interfaz de usuario, y correcciones críticas en el backend. Esta versión está lista para despliegue en ambiente de producción MVP. - -### Indicadores de Mejora -- **Densidad de información:** +50% más registros visibles por pantalla -- **Tiempo de respuesta UI:** Reducción de ~200ms en renderizado de tablas -- **Cobertura de filtros:** 100% funcional (estado y prioridad) -- **Correcciones backend:** 3 endpoints críticos corregidos -- **Archivos modificados:** 8 archivos (245 inserciones, 1633 eliminaciones) - ---- - -## 🎯 Objetivos Alcanzados - -### 1. Sistema de Filtros Funcional -**Problema:** Los filtros en el módulo de tickets no funcionaban correctamente, mostrando todos los registros sin importar los criterios seleccionados. - -**Solución Implementada:** -- Rediseño completo del sistema de filtros frontend/backend -- Implementación correcta de construcción de query strings -- Validación de parámetros en backend con mensajes de error descriptivos - -**Resultado:** Filtrado 100% funcional por estado y prioridad con actualización automática. - -### 2. Optimización de Interfaz de Usuario -**Problema:** Las tablas ocupaban demasiado espacio vertical, reduciendo la cantidad de información visible. - -**Solución Implementada:** -- Adopción del estilo compacto del módulo de auditoría -- Reducción de padding y tamaños de fuente -- Eliminación de columnas redundantes - -**Resultado:** 50% más contenido visible sin sacrificar legibilidad. - -### 3. Correcciones Backend Críticas -**Problema:** Múltiples endpoints presentaban errores 500 en producción. - -**Solución Implementada:** -- Corrección de manejo de timezone en comparaciones -- Implementación de eager loading para relaciones -- Generación explícita de UUIDs en creación de perfiles - -**Resultado:** 0 errores 500 en endpoints principales. - ---- - -## 🔧 Cambios Técnicos Detallados - -### Backend (Python/FastAPI) - -#### 1. Endpoint `/v1/tickets/` - Sistema de Filtros -**Archivo:** `backend/app/api/v1/endpoints/tickets.py` - -**Cambios realizados:** -```python -# ANTES (no funcional) -@router.get("/", response_model=List[TicketResponse]) -async def get_tickets( - skip: int = 0, - limit: int = 100, - status_filter: Optional[str] = None, # ❌ Nombre inconsistente - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - # Solo filtro por status, sin prioridad - if status_filter: - query = query.where(Ticket.status == status_filter) - -# DESPUÉS (funcional) -@router.get("/", response_model=List[TicketResponse]) -async def get_tickets( - skip: int = 0, - limit: int = 100, - status: Optional[str] = None, # ✅ Nombre correcto - priority: Optional[str] = None, # ✅ Filtro agregado - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - # Filtro por estado con validación - if status: - try: - status_enum = TicketStatus[status.upper()] - query = query.where(Ticket.status == status_enum) - except KeyError: - raise HTTPException( - status_code=400, - detail=f"Invalid status: {status}. Valid values: NEW, IN_PROGRESS, ..." - ) - - # Filtro por prioridad con validación - if priority: - try: - priority_enum = TicketPriority[priority.upper()] - query = query.where(Ticket.priority == priority_enum) - except KeyError: - raise HTTPException( - status_code=400, - detail=f"Invalid priority: {priority}. Valid values: LOW, MEDIUM, HIGH, URGENT" - ) -``` - -**Impacto:** -- Frontend y backend ahora usan los mismos nombres de parámetros -- Validación explícita previene errores de datos inválidos -- Soporte completo para filtrado combinado (estado + prioridad) -- Mensajes de error descriptivos facilitan debugging - ---- - -#### 2. Endpoint `/v1/sla/violations` - Corrección de Timezone -**Archivo:** `backend/app/api/v1/endpoints/sla.py` - -**Problema identificado:** -``` -TypeError: can't compare offset-naive and offset-aware datetimes -``` - -**Causa raíz:** -El campo `ticket.sla_response_due` viene de la base de datos como timestamp **naive** (sin zona horaria), pero `datetime.now(timezone.utc)` genera un timestamp **aware** (con UTC), causando incompatibilidad en comparaciones. - -**Solución implementada:** -```python -# ANTES -if ticket.sla_response_due: - now = datetime.now(timezone.utc) - if now > ticket.sla_response_due: # ❌ Error: comparación incompatible - violated_tickets.append(...) - -# DESPUÉS -if ticket.sla_response_due: - now = datetime.now(timezone.utc) - # Convertir timestamp de BD a UTC-aware - sla_due_aware = ticket.sla_response_due.replace(tzinfo=timezone.utc) - if now > sla_due_aware: # ✅ Ambos son UTC-aware - violated_tickets.append(...) -``` - -**Mejora adicional:** Eager Loading -```python -# ANTES: N+1 queries problem -result = await db.execute(query) -tickets = result.scalars().all() -for ticket in tickets: - user_email = ticket.created_by_user.email # ❌ Query adicional por cada ticket - -# DESPUÉS: Single query con JOIN -from sqlalchemy.orm import selectinload - -query = query.options( - selectinload(Ticket.created_by_user), - selectinload(Ticket.assigned_to_user), - selectinload(Ticket.category) -) -result = await db.execute(query) -tickets = result.scalars().all() -# ✅ Todas las relaciones cargadas en una sola consulta -``` - -**Impacto:** -- Eliminación de errores de comparación de timezone -- Reducción de queries a BD de O(n) a O(1) -- Mejora de rendimiento en listados grandes - ---- - -#### 3. Endpoint `/v1/client-profile/` - Generación de UUID -**Archivo:** `backend/app/api/v1/endpoints/client_profile.py` - -**Problema:** -``` -IntegrityError: null value in column "id" violates not-null constraint -IntegrityError: null value in column "created_at" violates not-null constraint -``` - -**Causa raíz:** -SQLAlchemy esperaba que la base de datos generara el UUID automáticamente, pero la columna no tenía `DEFAULT` en PostgreSQL. - -**Solución implementada:** - -1. **Código de aplicación:** -```python -# ANTES -db_profile = ClientProfile( - tenant_id=current_user.tenant_id, - user_id=current_user.id - # ❌ Falta id y created_at -) - -# DESPUÉS -import uuid -db_profile = ClientProfile( - id=uuid.uuid4(), # ✅ Generación explícita - tenant_id=current_user.tenant_id, - user_id=current_user.id -) -``` - -2. **Migración de base de datos:** -```python -# Archivo: backend/migrations/versions/fix_client_profiles_timestamps.py -def upgrade(): - op.alter_column('client_profiles', 'created_at', - server_default=sa.text('now()')) - op.alter_column('client_profiles', 'updated_at', - server_default=sa.text('now()')) - -def downgrade(): - op.alter_column('client_profiles', 'created_at', - server_default=None) - op.alter_column('client_profiles', 'updated_at', - server_default=None) -``` - -**Impacto:** -- Eliminación de errores 500 al crear perfiles vacíos -- Base de datos con defaults consistentes -- Código más robusto y predecible - ---- - -### Frontend (SvelteKit/TypeScript) - -#### 1. Módulo de Tickets - Sistema de Filtros -**Archivo:** `frontend-internal/src/routes/tickets/+page.svelte` - -**Arquitectura del cambio:** - -```typescript -// ANTES: Parámetros incorrectamente estructurados -async function loadData() { - const params: Record = {}; - if (filterStatus) params.status = filterStatus; - if (filterPriority) params.priority = filterPriority; - - // ❌ El helper api.get() no construía correctamente la URL con params objeto - const data = await api.get('/tickets/', params); -} - -// DESPUÉS: Query string explícito -async function loadData() { - // Usar URLSearchParams para construcción correcta - const queryParams = new URLSearchParams(); - queryParams.append('skip', '0'); - queryParams.append('limit', '100'); - - if (filterStatus) { - queryParams.append('status', filterStatus); - } - if (filterPriority) { - queryParams.append('priority', filterPriority); - } - - // ✅ URL completa con query string bien formado - const endpoint = `/tickets/?${queryParams.toString()}`; - const data = await api.get(endpoint); -} -``` - -**Layout de filtros optimizado:** -```svelte - -
-
- - -
-
-
- -
-
- - -
-
- - -
-
-
-``` - -**Beneficios:** -- Menor espacio vertical ocupado por filtros -- Actualización inmediata al cambiar criterios -- Interfaz más limpia sin botones innecesarios -- Labels más pequeños pero legibles - ---- - -#### 2. Tabla de Tickets - Diseño Compacto -**Archivo:** `frontend-internal/src/routes/tickets/+page.svelte` - -**Comparación de estilos:** - -| Elemento | Antes (v1.7.1) | Después (v1.8.0) | Reducción | -|----------|----------------|------------------|-----------| -| **Header padding** | `py-2` (8px) | `py-1.5` (6px) | -25% | -| **Cell padding** | `px-2 py-2` | `px-3 py-2` | 0% (optimizado) | -| **Font size header** | `text-xs font-semibold` | `text-xs font-medium uppercase` | Mejor jerarquía | -| **Font size body** | `text-xs` | `text-xs` | Mantenido | -| **Badge padding** | `px-2 py-0.5` | `px-2 py-1` | Mejor legibilidad | -| **Columnas totales** | 9 (inc. SLA) | 8 (sin SLA) | -11% ancho | - -**Estructura HTML mejorada:** -```html - - - - - - - - - - - - - - - -
TicketAsunto
...
- - - - - - - - - - - - - - - - -
- Ticket - Asunto
...
-``` - -**Mejoras visuales:** -- **Sticky header:** `sticky top-0 z-10` - encabezados fijos al hacer scroll -- **Transitions:** `transition-colors` en hover para mejor UX -- **Consistency:** Mismo padding `px-3` en todo el ancho -- **Typography:** `uppercase tracking-wider` en headers para mejor escaneado -- **Dividers:** Cambio de `divide-gray-300` a `divide-gray-200` (más sutil) - -**Badges optimizados:** -```svelte - - - {getStatusBadge(ticket.status).label} - - - - - {getStatusBadge(ticket.status).label} - -``` - -**Acciones con separador visual:** -```svelte - - - - - - - - - - | - - -``` - ---- - -#### 3. Gestión de Tenants - Toggle de Estado -**Archivo:** `frontend-internal/src/routes/tenants/+page.svelte` - -**Funcionalidad agregada:** Toggle switch para activar/desactivar tenants - -**Implementación:** -```svelte - - - - - - -{#each tenants as tenant (tenant.id)} - -{/each} -``` - -**Conceptos aplicados:** -- **Svelte Reactivity:** Uso de spread operator `[...tenants]` para forzar re-render -- **Keyed loops:** `{#each tenants as tenant (tenant.id)}` previene bugs de reordenamiento -- **Event modifiers:** `on:click|stopPropagation` previene navegación accidental -- **CSS Transitions:** Animación suave en cambio de estado - ---- - -## 📊 Análisis de Impacto - -### Rendimiento - -| Métrica | v1.7.1 | v1.8.0 | Mejora | -|---------|--------|--------|--------| -| **Queries por listado de tickets** | 21 (1 + 20*1 N+1) | 1 (eager loading) | 95% ↓ | -| **Tiempo de render tabla** | ~350ms | ~150ms | 57% ↓ | -| **Registros visibles** | 6-7 tickets | 12-14 tickets | 100% ↑ | -| **Filtros funcionales** | 0% | 100% | ∞ ↑ | -| **Errores 500 endpoints** | 3 endpoints | 0 endpoints | 100% ↓ | - -### Calidad de Código - -``` -Archivos modificados: 8 -Líneas agregadas: +245 -Líneas eliminadas: -1,633 -Ratio de limpieza: 6.7:1 (eliminamos más código del que agregamos) -``` - -**Archivos principales:** -1. `backend/app/api/v1/endpoints/tickets.py` - Sistema de filtros -2. `backend/app/api/v1/endpoints/sla.py` - Corrección timezone -3. `backend/app/api/v1/endpoints/client_profile.py` - UUID explicit -4. `frontend-internal/src/routes/tickets/+page.svelte` - UI optimizada -5. `frontend-internal/src/routes/tenants/+page.svelte` - Toggle status -6. `backend/migrations/versions/fix_client_profiles_timestamps.py` - Nueva migración - -### Deuda Técnica - -**Eliminada:** -- ✅ N+1 queries en endpoint de SLA violations -- ✅ Comparaciones timezone incompatibles -- ✅ Filtros no funcionales en tickets -- ✅ Código duplicado en tablas (archivos .backup eliminados) - -**Pendiente (no crítica):** -- ⚠️ Paginación en frontend (actualmente limit 100) -- ⚠️ Tests automatizados para nuevos endpoints -- ⚠️ Caché de categorías/sistemas/usuarios (cargados en cada request) - ---- - -## 🧪 Testing y Validación - -### Tests Realizados - -#### 1. Sistema de Filtros -``` -✅ Filtro por estado "NEW" → Solo tickets nuevos -✅ Filtro por prioridad "HIGH" → Solo tickets alta prioridad -✅ Filtro combinado (NEW + HIGH) → Intersección correcta -✅ Limpieza de filtros → Todos los tickets visibles -✅ Estados inválidos → Error 400 con mensaje descriptivo -``` - -#### 2. Endpoints Backend -``` -✅ GET /v1/tickets/?status=NEW → 200 OK -✅ GET /v1/tickets/?priority=URGENT → 200 OK -✅ GET /v1/tickets/?status=INVALID → 400 Bad Request -✅ GET /v1/sla/violations → 200 OK (sin error timezone) -✅ POST /v1/client-profile/ → 201 Created (con UUID) -``` - -#### 3. UI/UX -``` -✅ Tabla responsiva con overflow-x-auto -✅ Sticky headers funcionan en scroll vertical -✅ Hover effects con transiciones suaves -✅ Badges con colores semánticos correctos -✅ Toggle de tenants actualiza UI instantáneamente -``` - -### Casos de Prueba Manual - -**Escenario 1: Usuario filtra tickets urgentes** -1. Usuario accede a módulo de tickets -2. Selecciona prioridad "Urgente" en dropdown -3. Sistema recarga automáticamente -4. Solo se muestran tickets con prioridad URGENT -5. URL refleja filtro: `/tickets/?skip=0&limit=100&priority=URGENT` - -**Resultado:** ✅ Exitoso - -**Escenario 2: Administrador desactiva tenant** -1. Admin accede a gestión de tenants -2. Hace clic en toggle de un tenant activo -3. Toggle cambia a gris, estado actualiza a "inactive" -4. Toast muestra "Tenant desactivado" -5. Cambio persiste en base de datos - -**Resultado:** ✅ Exitoso - ---- - -## 🔄 Migraciones de Base de Datos - -### Migración: `fix_client_profiles_timestamps` - -**Propósito:** Agregar defaults de PostgreSQL para campos temporales - -**SQL generado:** -```sql --- Upgrade -ALTER TABLE client_profiles - ALTER COLUMN created_at SET DEFAULT now(); - -ALTER TABLE client_profiles - ALTER COLUMN updated_at SET DEFAULT now(); - --- Downgrade (rollback) -ALTER TABLE client_profiles - ALTER COLUMN created_at DROP DEFAULT; - -ALTER TABLE client_profiles - ALTER COLUMN updated_at DROP DEFAULT; -``` - -**Ejecución:** -```bash -# Aplicar migración -docker-compose exec backend alembic upgrade head - -# Verificar -docker-compose exec backend alembic current -# Output: fix_client_timestamps (head) -``` - -**Impacto:** 0 downtime, no modifica datos existentes - ---- - -## 📦 Despliegue - -### Pasos para Producción - -1. **Backup de base de datos:** -```bash -docker-compose exec postgres pg_dump -U postgres servicemanager > backup_pre_v1.8.0.sql -``` - -2. **Pull del código:** -```bash -git fetch --tags -git checkout v1.8.0 -``` - -3. **Rebuild de servicios modificados:** -```bash -docker-compose build backend frontend-internal -``` - -4. **Aplicar migraciones:** -```bash -docker-compose exec backend alembic upgrade head -``` - -5. **Restart de servicios:** -```bash -docker-compose restart backend frontend-internal -``` - -6. **Verificar health checks:** -```bash -curl http://localhost:8000/health -# Expected: {"status": "healthy"} -``` - -### Rollback Plan - -En caso de problemas críticos: - -```bash -# 1. Volver al código anterior -git checkout v1.7.1 - -# 2. Rollback de migración -docker-compose exec backend alembic downgrade -1 - -# 3. Rebuild y restart -docker-compose build backend frontend-internal -docker-compose restart backend frontend-internal - -# 4. Restaurar backup si es necesario -docker-compose exec -T postgres psql -U postgres servicemanager < backup_pre_v1.8.0.sql -``` - -**Tiempo estimado de rollback:** < 5 minutos - ---- - -## 🎓 Lecciones Aprendidas - -### 1. Timezone Handling -**Problema:** Comparaciones entre timestamps naive y aware causan TypeError. - -**Solución:** Siempre usar `datetime.now(timezone.utc)` y convertir timestamps de BD con `.replace(tzinfo=timezone.utc)`. - -**Best Practice:** -```python -# ❌ EVITAR -now = datetime.now() # Naive, depende de servidor - -# ✅ USAR -now = datetime.now(timezone.utc) # Aware, consistente -``` - -### 2. SQLAlchemy Eager Loading -**Problema:** N+1 queries degradan rendimiento significativamente. - -**Solución:** Usar `selectinload()` para cargar relaciones en una sola query. - -**Best Practice:** -```python -# ❌ EVITAR -tickets = await db.execute(select(Ticket)) -for ticket in tickets: - print(ticket.user.email) # Query por cada ticket - -# ✅ USAR -query = select(Ticket).options(selectinload(Ticket.user)) -tickets = await db.execute(query) -``` - -### 3. Svelte Reactivity -**Problema:** Cambios en objetos dentro de arrays no disparan re-render. - -**Solución:** Usar spread operator para crear nuevo array referencia. - -**Best Practice:** -```javascript -// ❌ EVITAR -tenant.status = 'active'; -// No re-render - -// ✅ USAR -tenant.status = 'active'; -tenants = [...tenants]; // Crea nueva referencia -``` - -### 4. API Query String Construction -**Problema:** Construcción manual de URLs puede causar codificación incorrecta. - -**Solución:** Usar `URLSearchParams` nativo de JavaScript. - -**Best Practice:** -```javascript -// ❌ EVITAR -let url = '/tickets/?status=' + status + '&priority=' + priority; - -// ✅ USAR -const params = new URLSearchParams(); -if (status) params.append('status', status); -if (priority) params.append('priority', priority); -const url = `/tickets/?${params.toString()}`; -``` - ---- - -## 📚 Documentación Actualizada - -### Nuevos Parámetros de API - -**Endpoint:** `GET /v1/tickets/` - -**Parámetros query:** -- `skip` (int): Offset para paginación (default: 0) -- `limit` (int): Cantidad máxima de resultados (default: 100) -- `status` (string, optional): Filtrar por estado - - Valores válidos: `NEW`, `IN_PROGRESS`, `WAITING_CUSTOMER`, `RESOLVED`, `CLOSED`, `REOPENED` -- `priority` (string, optional): Filtrar por prioridad - - Valores válidos: `LOW`, `MEDIUM`, `HIGH`, `URGENT` - -**Ejemplo de uso:** -```bash -# Tickets nuevos de alta prioridad -GET /v1/tickets/?status=NEW&priority=HIGH - -# Solo tickets urgentes -GET /v1/tickets/?priority=URGENT - -# Tickets en progreso (paginados) -GET /v1/tickets/?status=IN_PROGRESS&skip=20&limit=20 -``` - -**Respuestas:** -- `200 OK`: Lista de tickets filtrados -- `400 Bad Request`: Parámetro inválido -- `401 Unauthorized`: Token expirado/inválido - ---- - -## 🔐 Consideraciones de Seguridad - -### Validación de Inputs -✅ **Implementado:** Todos los filtros validan contra enums definidos. - -```python -# Previene SQL injection y valores arbitrarios -try: - status_enum = TicketStatus[status.upper()] -except KeyError: - raise HTTPException(status_code=400, detail="Invalid status") -``` - -### Multi-tenancy -✅ **Mantenido:** Todos los endpoints filtran por `tenant_id`. - -```python -query = select(Ticket).where(Ticket.tenant_id == current_user.tenant_id) -``` - -### RBAC (Role-Based Access Control) -✅ **Preservado:** Clientes solo ven sus propios tickets. - -```python -if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]: - query = query.where(Ticket.created_by == current_user.id) -``` - ---- - -## 📈 Próximos Pasos (v1.9.0) - -### Funcionalidades Planificadas -1. **Paginación completa:** - - Botones prev/next en frontend - - Indicador de página actual - - Total de registros - -2. **Filtros adicionales:** - - Búsqueda por texto (subject/description) - - Filtro por rango de fechas - - Filtro por categoría - -3. **Exportación de datos:** - - Exportar tickets a CSV - - Exportar a PDF con filtros aplicados - -4. **Optimizaciones:** - - Caché de categorías/sistemas en localStorage - - Lazy loading de imágenes/avatares - - Debounce en búsquedas de texto - -### Mejoras Técnicas -1. Tests automatizados (pytest + Svelte Testing Library) -2. Documentación OpenAPI más completa -3. Metrics con Prometheus -4. Logging estructurado mejorado - ---- - -## 👥 Créditos - -**Desarrollador:** Equipo de Desarrollo Aduanasoft -**Revisión Técnica:** GitHub Copilot -**QA:** Testing manual interno -**Arquitectura:** Clean Architecture + Domain-Driven Design - ---- - -## 📞 Soporte - -Para reportar issues o consultas sobre esta versión: -- **Email:** dev@aduanasoft.com -- **Sistema:** ServiceManagerWeb Internal -- **Versión:** 1.8.0 -- **Fecha de release:** 17/02/2026 - ---- - -## 🏁 Conclusión - -La versión 1.8.0 consolida el sistema como **MVP production-ready**, con: -- ✅ Sistema de filtros totalmente funcional -- ✅ UI optimizada para mayor densidad de información -- ✅ 0 errores críticos en endpoints principales -- ✅ Codebase más limpio (-1633 líneas) -- ✅ Mejor rendimiento en queries (95% reducción) - -**Estado del proyecto:** Listo para despliegue en producción. - ---- - -*Documento generado automáticamente para ServiceManagerWeb v1.8.0* -*© 2026 Aduanasoft - Todos los derechos reservados* diff --git a/OPTIMIZACIONES_RENDIMIENTO.md b/OPTIMIZACIONES_RENDIMIENTO.md deleted file mode 100644 index 74f5b8a..0000000 --- a/OPTIMIZACIONES_RENDIMIENTO.md +++ /dev/null @@ -1,343 +0,0 @@ -# Optimizaciones de Rendimiento - ServiceManagerWeb - -## 🎯 Estado Actual -El sistema funciona correctamente, pero podemos implementar mejoras para hacerlo más rápido. - -## 🚀 Optimizaciones Implementables - -### 1. **Backend - Base de Datos** (ALTO IMPACTO) - -#### A. Aumentar Pool de Conexiones -**Archivo**: `backend/app/core/database.py` - -```python -# Actual -engine = create_async_engine( - settings.DATABASE_URL, - pool_size=5, # ← Aumentar a 20 - max_overflow=10, # ← Aumentar a 30 - pool_pre_ping=True, -) - -# Optimizado -engine = create_async_engine( - settings.DATABASE_URL, - pool_size=20, # Más conexiones concurrentes - max_overflow=30, # Más overflow para picos - pool_pre_ping=True, - pool_recycle=3600, -) -``` - -**Impacto**: ⚡ 30-50% más rápido en endpoints con DB - ---- - -#### B. Agregar Índices Faltantes -**Ejecutar migrations**: - -```sql --- Índices para queries frecuentes -CREATE INDEX CONCURRENTLY idx_tickets_status_tenant ON tickets(status, tenant_id); -CREATE INDEX CONCURRENTLY idx_tickets_assigned_to ON tickets(assigned_to); -CREATE INDEX CONCURRENTLY idx_tickets_created_at ON tickets(created_at DESC); -CREATE INDEX CONCURRENTLY idx_users_email_tenant ON users(email, tenant_id); -CREATE INDEX CONCURRENTLY idx_audit_logs_tenant_created ON audit_logs(tenant_id, created_at DESC); -``` - -**Impacto**: ⚡ 40-70% más rápido en listados y búsquedas - ---- - -### 2. **Backend - Caché con Redis** (ALTO IMPACTO) - -#### Crear servicio de caché -**Nuevo archivo**: `backend/app/core/cache.py` - -```python -"""Redis caching service""" -from redis import asyncio as aioredis -from typing import Optional, Any -import json -from app.core.config import get_settings - -settings = get_settings() - -class CacheService: - def __init__(self): - self.redis = None - - async def connect(self): - self.redis = await aioredis.from_url( - settings.REDIS_URL, - encoding="utf-8", - decode_responses=True - ) - - async def get(self, key: str) -> Optional[Any]: - if not self.redis: - await self.connect() - value = await self.redis.get(key) - return json.loads(value) if value else None - - async def set(self, key: str, value: Any, ttl: int = 300): - if not self.redis: - await self.connect() - await self.redis.setex(key, ttl, json.dumps(value)) - - async def delete(self, key: str): - if not self.redis: - await self.connect() - await self.redis.delete(key) - -cache = CacheService() -``` - -#### Usar en endpoints frecuentes: - -```python -# Ejemplo: Cachear listado de categorías -@router.get("/categories") -async def list_categories(db: AsyncSession = Depends(get_db)): - cache_key = f"categories:tenant:{tenant_id}" - - # Intentar cache - cached = await cache.get(cache_key) - if cached: - return cached - - # Si no hay cache, query DB - result = await db.execute(select(Category)) - categories = result.scalars().all() - - # Guardar en cache por 5 minutos - await cache.set(cache_key, categories, ttl=300) - return categories -``` - -**Impacto**: ⚡ 80-95% más rápido en datos que no cambian frecuentemente - ---- - -### 3. **Backend - Uvicorn Workers** (MEDIO IMPACTO) - -#### Actualizar Dockerfile -**Archivo**: `docker/Dockerfile.backend` - -```dockerfile -# Cambiar la última línea de: -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] - -# A modo producción: -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"] -``` - -**Nota**: Quitar `--reload` en producción (consume recursos). - -**Impacto**: ⚡ 2-4x más throughput (requests por segundo) - ---- - -### 4. **Frontend - Code Splitting y Lazy Loading** (MEDIO IMPACTO) - -#### Configurar lazy loading en rutas -**Archivo**: `frontend-internal/src/routes/+layout.svelte` - -```typescript -// En lugar de importar todo: -import HeavyComponent from '$lib/components/HeavyComponent.svelte'; - -// Usar dynamic imports: -const HeavyComponent = () => import('$lib/components/HeavyComponent.svelte'); -``` - -#### Optimizar build de Vite -**Archivo**: `frontend-internal/vite.config.js` - -```javascript -export default { - build: { - rollupOptions: { - output: { - manualChunks: { - 'vendor': ['svelte', 'svelte/store'], - 'charts': ['chart.js'], // Si usas charts - } - } - } - } -} -``` - -**Impacto**: ⚡ 40-60% más rápido el load inicial del frontend - ---- - -### 5. **Queries SQL - Eager Loading** (ALTO IMPACTO) - -#### Usar selectinload para relaciones -**Ejemplo en endpoints de tickets**: - -```python -# Antes (N+1 queries) -query = select(Ticket).where(Ticket.tenant_id == tenant_id) - -# Después (1 query con joins) -query = select(Ticket).options( - selectinload(Ticket.category), - selectinload(Ticket.assigned_user), - selectinload(Ticket.comments) -).where(Ticket.tenant_id == tenant_id) -``` - -**Impacto**: ⚡ 50-80% más rápido al traer relaciones - ---- - -### 6. **Logging en Producción** (MEDIO IMPACTO) - -#### Reducir logging en producción -**Archivo**: `.env` - -```bash -# Development -DEBUG=true -LOG_LEVEL=INFO - -# Production (cambiar a) -DEBUG=false -LOG_LEVEL=WARNING -``` - -**Impacto**: ⚡ 10-15% menos overhead - ---- - -### 7. **Docker - Recursos** (BAJO IMPACTO) - -#### Asignar más recursos en docker-compose -**Archivo**: `docker-compose.yml` - -```yaml -backend: - # ... config existente - deploy: - resources: - limits: - cpus: '2.0' - memory: 2G - reservations: - cpus: '1.0' - memory: 512M - -postgres: - # ... config existente - deploy: - resources: - limits: - cpus: '2.0' - memory: 2G -``` - ---- - -## 📊 Prioridades de Implementación - -### **Fase 1 - Quick Wins** (1-2 horas) -1. ✅ Aumentar pool de DB -2. ✅ Quitar `--reload` en producción -3. ✅ Reducir logging (LOG_LEVEL=WARNING) - -**Ganancia esperada**: 30-40% mejora general - ---- - -### **Fase 2 - Optimizaciones Importantes** (2-4 horas) -1. ✅ Agregar índices de DB -2. ✅ Implementar caché con Redis -3. ✅ Eager loading en queries complejas - -**Ganancia esperada**: 50-70% mejora en endpoints cacheables - ---- - -### **Fase 3 - Optimizaciones Avanzadas** (4-8 horas) -1. ✅ Uvicorn workers múltiples -2. ✅ Frontend code splitting -3. ✅ Optimización de queries lentas - -**Ganancia esperada**: 2-3x mejora en throughput total - ---- - -## 🔧 Comandos Rápidos - -### Implementar Fase 1 (copiar y ejecutar): - -```bash -# 1. Editar database.py (aumentar pools) -# Ver sección 1.A arriba - -# 2. Editar Dockerfile.backend (quitar reload) -# Ver sección 3 arriba - -# 3. Editar .env -echo "DEBUG=false" >> .env -echo "LOG_LEVEL=WARNING" >> .env - -# 4. Reiniciar servicios -docker-compose restart backend -``` - ---- - -## 📈 Monitorear Mejoras - -```bash -# Medir tiempo de respuesta ANTES -curl -w "@-" -o /dev/null -s http://localhost:8000/v1/tickets <<'EOF' - time_total: %{time_total}s\n -EOF - -# Implementar optimizaciones... - -# Medir tiempo de respuesta DESPUÉS -curl -w "@-" -o /dev/null -s http://localhost:8000/v1/tickets <<'EOF' - time_total: %{time_total}s\n -EOF -``` - ---- - -## ⚡ Resultados Esperados - -| Métrica | Actual | Optimizado | Mejora | -|---------|--------|------------|--------| -| Login | ~300ms | ~100ms | 3x | -| Listar tickets | ~500ms | ~150ms | 3.3x | -| Crear ticket | ~400ms | ~200ms | 2x | -| Dashboard SLA | ~800ms | ~200ms | 4x (con cache) | -| Load frontend | ~2s | ~800ms | 2.5x | - ---- - -## 🎓 Mejores Prácticas Adicionales - -1. **Paginación siempre**: Nunca devolver listados sin límite -2. **Índices compuestos**: Para queries con múltiples WHERE -3. **Redis para sesiones**: Mover JWT refresh tokens a Redis -4. **CDN para assets**: Servir JS/CSS desde CDN en producción -5. **HTTP/2**: Configurar Nginx con HTTP/2 - ---- - -## 📝 Notas Importantes - -- **Redis ya está corriendo**: Solo falta implementar CacheService -- **No optimizar prematuramente**: Medir primero, optimizar después -- **Testing**: Probar cada optimización para evitar regresiones -- **Monitoring**: Agregar métricas con Prometheus/Grafana (opcional) - ---- - -¿Quieres que implemente alguna de estas optimizaciones ahora? diff --git a/backend/app/api/schemas/audit.py b/backend/app/api/schemas/audit.py index fdad5bd..108805a 100644 --- a/backend/app/api/schemas/audit.py +++ b/backend/app/api/schemas/audit.py @@ -57,6 +57,7 @@ class AuditLogResponse(AuditLogBase): class SecurityThreatPattern(BaseModel): """Patrón de amenaza detectado.""" + id: str = Field(description="ID único de la amenaza (pattern_id)") type: str = Field(description="Tipo de amenaza (brute_force, privilege_escalation, etc.)") severity: str = Field(description="Severidad: low, medium, high, critical") description: str = Field(description="Descripción de la amenaza") @@ -65,7 +66,7 @@ class SecurityThreatPattern(BaseModel): affected_users: list[str] = Field(default=[], description="Usuarios afectados") first_seen: datetime = Field(description="Primera ocurrencia") last_seen: datetime = Field(description="Última ocurrencia") - recommendations: list[str] = Field(default=[], description="Recomendaciones de acción") + recommended_action: str = Field(default="", description="Acción recomendada") class SecurityAnalysisResponse(BaseModel): diff --git a/backend/app/api/v1/audit_helpers.py b/backend/app/api/v1/audit_helpers.py index d616856..5d71ae9 100644 --- a/backend/app/api/v1/audit_helpers.py +++ b/backend/app/api/v1/audit_helpers.py @@ -123,7 +123,7 @@ def detect_mass_deletions(logs: List[AuditLog], now: datetime) -> List[dict]: "status": status, "incident_type": "mass_deletion", "affected_user": group['user'], - "source_ip": group['logs'][0].ip_address, + "source_ip": str(group['logs'][0].ip_address) if group['logs'][0].ip_address else None, "evidence": [f"{log.action} - {log.resource_type} - {log.created_at.strftime('%H:%M:%S')}" for log in group['logs'][:5]], "metadata": { "total_deletions": group['count'], @@ -207,7 +207,7 @@ def detect_privilege_escalation(logs: List[AuditLog]) -> List[dict]: "status": "investigating", "incident_type": "privilege_escalation", "affected_user": log.user.email, - "source_ip": log.ip_address, + "source_ip": str(log.ip_address) if log.ip_address else None, "evidence": [f"Cambio de rol: {old_role} → {new_role} - {log.created_at.strftime('%Y-%m-%d %H:%M')}"], "metadata": { "old_role": old_role, diff --git a/backend/app/api/v1/endpoints/audit.py b/backend/app/api/v1/endpoints/audit.py index 2c95534..d713dfd 100644 --- a/backend/app/api/v1/endpoints/audit.py +++ b/backend/app/api/v1/endpoints/audit.py @@ -152,38 +152,47 @@ async def get_security_analysis(all_tenants: bool = Query(False), current_user: threat_patterns = [] if failed_logins >= 5: + affected_ips_list = [str(log.ip_address) for log in logs if log.action == 'user.login_failed' and log.ip_address] threat_patterns.append(SecurityThreatPattern( - pattern_id="brute_force_attempt", + id="brute_force_attempt", + type="brute_force", description=f"Se detectaron {failed_logins} intentos fallidos de login en las últimas 24h", severity="high" if failed_logins >= 20 else "medium", occurrences=failed_logins, first_seen=min((log.created_at for log in logs if log.action == 'user.login_failed'), default=now), last_seen=max((log.created_at for log in logs if log.action == 'user.login_failed'), default=now), - affected_resources=[str(log.ip_address) for log in logs if log.action == 'user.login_failed' and log.ip_address][:5], + affected_ips=list(set(affected_ips_list))[:5], + affected_users=[], recommended_action="Considerar bloquear IPs con múltiples fallos" )) if mass_deletions >= 10: + deleting_users = [log.user.email for log in logs if '.delete' in log.action and log.user] threat_patterns.append(SecurityThreatPattern( - pattern_id="mass_deletion", + id="mass_deletion", + type="mass_deletion", description=f"Se detectaron {mass_deletions} eliminaciones en las últimas 24h", severity="critical" if mass_deletions >= 50 else "high", occurrences=mass_deletions, first_seen=min((log.created_at for log in logs if '.delete' in log.action), default=now), last_seen=max((log.created_at for log in logs if '.delete' in log.action), default=now), - affected_resources=[log.resource_type for log in logs if '.delete' in log.action][:5], + affected_ips=[], + affected_users=list(set(deleting_users))[:5], recommended_action="Revisar qué usuarios están eliminando recursos" )) if privilege_changes >= 3: + affected_users_list = [log.user.email for log in logs if log.action == 'user.update' and log.user and log.new_values and 'role' in log.new_values] threat_patterns.append(SecurityThreatPattern( - pattern_id="suspicious_privilege_changes", + id="suspicious_privilege_changes", + type="privilege_escalation", description=f"Se detectaron {privilege_changes} cambios de privilegios en las últimas 24h", severity="high", occurrences=privilege_changes, first_seen=min((log.created_at for log in logs if log.action == 'user.update' and log.new_values and 'role' in log.new_values), default=now), last_seen=max((log.created_at for log in logs if log.action == 'user.update' and log.new_values and 'role' in log.new_values), default=now), - affected_resources=[log.user.email for log in logs if log.action == 'user.update' and log.user and log.new_values and 'role' in log.new_values][:5], + affected_ips=[], + affected_users=list(set(affected_users_list))[:5], recommended_action="Auditar cambios de roles recientes" )) diff --git a/backend/app/api/v1/endpoints/audit_backup.py b/backend/app/api/v1/endpoints/audit_backup.py new file mode 100644 index 0000000..457dad1 --- /dev/null +++ b/backend/app/api/v1/endpoints/audit_backup.py @@ -0,0 +1,1033 @@ +""" +Audit Endpoints - ServiceManagerWeb + +Endpoints para consulta de logs de auditor├¡a. +Solo accesible por roles: ADMIN, SUPPORT_MANAGER, AUDITOR +""" + +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_, or_, desc +from sqlalchemy.orm import selectinload +from typing import Optional, List +from datetime import datetime, timedelta, timezone +import uuid +import structlog + +from app.core.database import get_db +from app.api.deps import get_current_user, get_current_tenant +from app.models.user import User, UserRole +from app.models.tenant import Tenant +from app.models.audit import AuditLog +from app.services.audit_service import AuditService +from app.api.schemas.audit import ( + AuditLogResponse, + AuditLogListResponse, + AuditLogFilters, + AuditLogStats, + SecurityAnalysisResponse, + SecurityThreatPattern, + SecurityActionRequest, + SecurityActionResponse, + SecurityIncidentResponse, + SecurityIncidentListResponse +) + +router = APIRouter() +logger = structlog.get_logger(__name__) + + +def require_auditor_role(current_user: User = Depends(get_current_user)) -> User: + """ + Dependency que verifica que el usuario tenga rol de auditor. + + Solo ADMIN, SUPPORT_MANAGER y AUDITOR pueden ver logs de auditor├¡a. + """ + allowed_roles = [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR] + + if current_user.role not in allowed_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Solo usuarios con rol ADMIN, SUPPORT_MANAGER o AUDITOR pueden acceder a logs de auditor├¡a" + ) + + return current_user + + +@router.get("/", response_model=AuditLogListResponse) +async def get_audit_logs( + # Paginaci├│n + page: int = Query(default=1, ge=1, description="N├║mero de p├ígina"), + per_page: int = Query(default=50, ge=1, le=100, description="Registros por p├ígina"), + + # Filtros + user_id: Optional[uuid.UUID] = Query(None, description="Filtrar por usuario"), + action: Optional[str] = Query(None, description="Filtrar por acci├│n"), + resource_type: Optional[str] = Query(None, description="Filtrar por tipo de recurso"), + resource_id: Optional[uuid.UUID] = Query(None, description="Filtrar por ID de recurso"), + date_from: Optional[datetime] = Query(None, description="Fecha desde"), + date_to: Optional[datetime] = Query(None, description="Fecha hasta"), + search: Optional[str] = Query(None, description="B├║squeda en acci├│n o email"), + # Multi-tenant filters (solo ADMIN/SUPPORT_MANAGER) + tenant_id: Optional[uuid.UUID] = Query(None, description="Ver logs de un tenant específico"), + all_tenants: bool = Query(False, description="Ver logs de todos los tenants"), + # Dependencies + current_user: User = Depends(require_auditor_role), + current_tenant: Tenant = Depends(get_current_tenant), + db: AsyncSession = Depends(get_db) +): + """ + Obtener logs de auditor├¡a con filtros y paginaci├│n. + + **Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR + + **Filtros disponibles**: + - `user_id`: Acciones de un usuario espec├¡fico + - `action`: Tipo de acci├│n (ej: "ticket.create") + - `resource_type`: Tipo de recurso (ej: "ticket") + - `resource_id`: ID de recurso espec├¡fico + - `date_from`, `date_to`: Rango de fechas + - `search`: B├║squeda en acciones + + **Retorna**: Lista paginada de audit logs + """ + logger.info( + "Fetching audit logs", + user_id=str(current_user.id), + tenant_id=str(current_tenant.id), + filters={ + "user_id": str(user_id) if user_id else None, + "action": action, + "resource_type": resource_type, + "page": page, + "tenant_filter": str(tenant_id) if tenant_id else None, + "all_tenants": all_tenants + } + ) + + # Determinar el filtro de tenant + # Solo ADMIN y SUPPORT_MANAGER pueden ver otros tenants o todos los tenants + can_see_all_tenants = current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER] + + # Query base con filtro de tenant dinámico + query = select(AuditLog).options(selectinload(AuditLog.user)) + + if all_tenants and can_see_all_tenants: + # Ver todos los tenants (no agregar filtro de tenant) + pass + elif tenant_id and can_see_all_tenants: + # Ver un tenant específico + query = query.where(AuditLog.tenant_id == tenant_id) + else: + # Ver solo el tenant actual (comportamiento default) + query = query.where(AuditLog.tenant_id == current_tenant.id) + + # Aplicar filtros + if user_id: + query = query.where(AuditLog.user_id == user_id) + + if action: + query = query.where(AuditLog.action == action) + + if resource_type: + query = query.where(AuditLog.resource_type == resource_type) + + if resource_id: + query = query.where(AuditLog.resource_id == resource_id) + + if date_from: + query = query.where(AuditLog.created_at >= date_from) + + if date_to: + # El frontend ya envía el timestamp correcto + query = query.where(AuditLog.created_at < date_to) + + if search: + # B├║squeda en action + search_filter = AuditLog.action.ilike(f"%{search}%") + query = query.where(search_filter) + + # Ordenar por fecha descendente (m├ís recientes primero) + query = query.order_by(desc(AuditLog.created_at)) + + # Contar total antes de paginar + count_query = select(func.count()).select_from(query.subquery()) + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + + # Aplicar paginaci├│n + offset = (page - 1) * per_page + query = query.offset(offset).limit(per_page) + + # Ejecutar query + result = await db.execute(query) + logs = result.scalars().all() + + # Calcular total de p├íginas + total_pages = (total + per_page - 1) // per_page + + # Convertir a response schema (agregar info del usuario) + logs_response = [] + for log in logs: + log_dict = { + "id": log.id, + "tenant_id": log.tenant_id, + "user_id": log.user_id, + "action": log.action, + "resource_type": log.resource_type, + "resource_id": log.resource_id, + "ip_address": str(log.ip_address) if log.ip_address else None, + "user_agent": log.user_agent, + "correlation_id": log.correlation_id, + "old_values": log.old_values, + "new_values": log.new_values, + "metadata": log.extra_metadata, + "created_at": log.created_at, + "action_display": log.action_display, + "user_email": None, + "user_name": None + } + + # Agregar info del usuario si existe + if log.user: + log_dict["user_email"] = log.user.email + log_dict["user_name"] = log.user.full_name + log_dict["user_role"] = log.user.role.value if hasattr(log.user.role, 'value') else str(log.user.role) + + logs_response.append(AuditLogResponse(**log_dict)) + + return AuditLogListResponse( + logs=logs_response, + total=total, + page=page, + per_page=per_page, + total_pages=total_pages + ) + + +@router.get("/stats", response_model=AuditLogStats) +async def get_audit_stats( + all_tenants: bool = Query(False, description="Ver stats de todos los tenants"), + current_user: User = Depends(require_auditor_role), + current_tenant: Tenant = Depends(get_current_tenant), + db: AsyncSession = Depends(get_db) +): + """ + Obtener estadísticas de auditoría del tenant (o todos los tenants si es ADMIN). + + **Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR + + **Retorna**: Estadísticas de actividad + """ + can_see_all_tenants = current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER] + + logger.info( + "Fetching audit stats", + user_id=str(current_user.id), + tenant_id=str(current_tenant.id), + all_tenants=all_tenants, + can_see_all=can_see_all_tenants + ) + + now = datetime.now(timezone.utc) + + # Determinar si aplicar filtro de tenant + apply_tenant_filter = not (all_tenants and can_see_all_tenants) + + # Total de acciones + total_query = select(func.count()).select_from(AuditLog) + if apply_tenant_filter: + total_query = total_query.where(AuditLog.tenant_id == current_tenant.id) + total_result = await db.execute(total_query) + total_actions = total_result.scalar() or 0 + + # Acciones hoy (├║ltimas 24 horas) + today_start = now - timedelta(days=1) + today_query = select(func.count()).select_from(AuditLog).where( + AuditLog.created_at >= today_start + ) + if apply_tenant_filter: + today_query = today_query.where(AuditLog.tenant_id == current_tenant.id) + today_result = await db.execute(today_query) + actions_today = today_result.scalar() or 0 + + # Acciones esta semana (├║ltimos 7 d├¡as) + week_start = now - timedelta(days=7) + week_query = select(func.count()).select_from(AuditLog).where( + AuditLog.created_at >= week_start + ) + if apply_tenant_filter: + week_query = week_query.where(AuditLog.tenant_id == current_tenant.id) + week_result = await db.execute(week_query) + actions_this_week = week_result.scalar() or 0 + + # Top 5 acciones m├ís frecuentes + top_actions_query = select( + AuditLog.action, + func.count(AuditLog.id).label('count') + ) + if apply_tenant_filter: + top_actions_query = top_actions_query.where(AuditLog.tenant_id == current_tenant.id) + top_actions_query = top_actions_query.group_by( + AuditLog.action + ).order_by( + desc('count') + ).limit(5) + + top_actions_result = await db.execute(top_actions_query) + top_actions = {row.action: row.count for row in top_actions_result} + + # Acciones por tipo de recurso + by_resource_query = select( + AuditLog.resource_type, + func.count(AuditLog.id).label('count') + ) + if apply_tenant_filter: + by_resource_query = by_resource_query.where(AuditLog.tenant_id == current_tenant.id) + by_resource_query = by_resource_query.group_by( + AuditLog.resource_type + ).order_by( + desc('count') + ) + + by_resource_result = await db.execute(by_resource_query) + by_resource_type = {row.resource_type: row.count for row in by_resource_result} + + # Top usuarios (con join a users para obtener nombres) + top_users_query = select( + User.email, + func.count(AuditLog.id).label('count') + ).join( + User, AuditLog.user_id == User.id + ) + if apply_tenant_filter: + top_users_query = top_users_query.where(AuditLog.tenant_id == current_tenant.id) + top_users_query = top_users_query.group_by( + User.email + ).order_by( + desc('count') + ).limit(5) + + top_users_result = await db.execute(top_users_query) + top_users = {row.email: row.count for row in top_users_result} + + # Acciones cr├¡ticas hoy (delete, update sensibles, etc.) + critical_conditions = [ + AuditLog.created_at >= today_start, + or_( + AuditLog.action.like('%.delete'), + AuditLog.action.like('user.update'), + AuditLog.action.like('%.assign'), + AuditLog.action.in_(['user.login_failed', 'user.logout']) + ) + ] + if apply_tenant_filter: + critical_conditions.append(AuditLog.tenant_id == current_tenant.id) + + critical_actions_query = select(func.count()).select_from(AuditLog).where( + and_(*critical_conditions) + ) + critical_result = await db.execute(critical_actions_query) + critical_actions_today = critical_result.scalar() or 0 + + return AuditLogStats( + total_actions=total_actions, + actions_today=actions_today, + actions_this_week=actions_this_week, + critical_actions_today=critical_actions_today, + top_actions=top_actions, + top_users=top_users, + by_resource_type=by_resource_type + ) + + +@router.get("/{log_id}", response_model=AuditLogResponse) +async def get_audit_log_detail( + log_id: uuid.UUID, + current_user: User = Depends(require_auditor_role), + current_tenant: Tenant = Depends(get_current_tenant), + db: AsyncSession = Depends(get_db) +): + """ + Obtener detalle de un audit log espec├¡fico. + + **Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR + + **Retorna**: Detalle completo del audit log + """ + # Buscar el log + query = select(AuditLog).where( + and_( + AuditLog.id == log_id, + AuditLog.tenant_id == current_tenant.id + ) + ) + + result = await db.execute(query) + log = result.scalar_one_or_none() + + if not log: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Audit log {log_id} no encontrado" + ) + + # Convertir a response + log_dict = { + "id": log.id, + "tenant_id": log.tenant_id, + "user_id": log.user_id, + "action": log.action, + "resource_type": log.resource_type, + "resource_id": log.resource_id, + "ip_address": str(log.ip_address) if log.ip_address else None, + "user_agent": log.user_agent, + "correlation_id": log.correlation_id, + "old_values": log.old_values, + "new_values": log.new_values, + "metadata": log.extra_metadata, + "created_at": log.created_at, + "action_display": log.action_display, + "user_email": None, + "user_name": None + } + + if log.user: + log_dict["user_email"] = log.user.email + log_dict["user_name"] = log.user.full_name + + return AuditLogResponse(**log_dict) + + +# =================================== +# SECURITY ANALYSIS ENDPOINTS +# =================================== + +@router.get("/security/analysis", response_model=SecurityAnalysisResponse) +async def get_security_analysis( + hours: int = Query(default=24, ge=1, le=168, description="Período de análisis en horas"), + current_user: User = Depends(require_auditor_role), + current_tenant: Tenant = Depends(get_current_tenant), + db: AsyncSession = Depends(get_db) +): + """ + Análisis de seguridad y detección de amenazas. + + **Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR + + **Detecta**: + - Intentos de fuerza bruta (login_failed) + - Escalada de privilegios + - Eliminaciones masivas + - Accesos desde IPs sospechosas + - Patrones anómalos de actividad + + **Nota**: El contador `critical_actions_count` usa la misma lógica + que el módulo de Auditoría (eliminaciones, login fallidos, cambios de rol/estado). + + **Retorna**: Análisis completo con amenazas y recomendaciones + """ + logger.info( + "Security analysis requested", + user_id=str(current_user.id), + tenant_id=str(current_tenant.id), + hours=hours + ) + + now = datetime.now(timezone.utc) + analysis_start = now - timedelta(hours=hours) + + threats = [] + failed_login_attempts = 0 + suspicious_ips = set() + + # 1. DETECCIÓN DE FUERZA BRUTA + brute_force_query = select( + AuditLog.ip_address, + func.count(AuditLog.id).label('attempts'), + func.min(AuditLog.created_at).label('first_seen'), + func.max(AuditLog.created_at).label('last_seen') + ).where( + and_( + AuditLog.tenant_id == current_tenant.id, + AuditLog.action == 'user.login_failed', + AuditLog.created_at >= analysis_start + ) + ).group_by(AuditLog.ip_address).having(func.count(AuditLog.id) >= 5) + + brute_force_result = await db.execute(brute_force_query) + brute_force_ips = brute_force_result.all() + + for ip_data in brute_force_ips: + if ip_data.ip_address: + suspicious_ips.add(str(ip_data.ip_address)) + failed_login_attempts += ip_data.attempts + + severity = "high" if ip_data.attempts > 20 else "medium" if ip_data.attempts > 10 else "low" + + threats.append(SecurityThreatPattern( + type="brute_force_attack", + severity=severity, + description=f"Ataque de fuerza bruta detectado desde {ip_data.ip_address}", + occurrences=ip_data.attempts, + affected_ips=[str(ip_data.ip_address)], + affected_users=[], + first_seen=ip_data.first_seen, + last_seen=ip_data.last_seen, + recommendations=[ + f"Bloquear IP {ip_data.ip_address} temporalmente", + "Revisar logs de firewall", + "Considerar implementar CAPTCHA", + "Notificar al equipo de seguridad" + ] + )) + + # 2. ESCALADA DE PRIVILEGIOS + privilege_query = select( + User.email, + func.count(AuditLog.id).label('changes'), + func.min(AuditLog.created_at).label('first_seen'), + func.max(AuditLog.created_at).label('last_seen') + ).join( + User, AuditLog.user_id == User.id + ).where( + and_( + AuditLog.tenant_id == current_tenant.id, + AuditLog.action == 'user.update', + AuditLog.created_at >= analysis_start, + AuditLog.new_values.op('?')('role') + ) + ).group_by(User.email).having(func.count(AuditLog.id) >= 3) + + privilege_result = await db.execute(privilege_query) + privilege_changes = privilege_result.all() + + for priv_data in privilege_changes: + threats.append(SecurityThreatPattern( + type="privilege_escalation", + severity="critical", + description=f"Posible escalada de privilegios - {priv_data.email} ha modificado roles {priv_data.changes} veces", + occurrences=priv_data.changes, + affected_ips=[], + affected_users=[priv_data.email], + first_seen=priv_data.first_seen, + last_seen=priv_data.last_seen, + recommendations=[ + f"Revisar permisos del usuario {priv_data.email}", + "Auditar todos los cambios de roles realizados", + "Verificar si los cambios fueron autorizados", + "Considerar revertir cambios no autorizados" + ] + )) + + # 3. ELIMINACIONES MASIVAS + deletion_query = select( + User.email, + func.count(AuditLog.id).label('deletions'), + func.min(AuditLog.created_at).label('first_seen'), + func.max(AuditLog.created_at).label('last_seen') + ).join( + User, AuditLog.user_id == User.id + ).where( + and_( + AuditLog.tenant_id == current_tenant.id, + AuditLog.action.like('%.delete'), + AuditLog.created_at >= analysis_start + ) + ).group_by(User.email).having(func.count(AuditLog.id) >= 10) + + deletion_result = await db.execute(deletion_query) + mass_deletions = deletion_result.all() + + for del_data in mass_deletions: + threats.append(SecurityThreatPattern( + type="mass_deletion", + severity="high", + description=f"Eliminaciones masivas detectadas - {del_data.email} ha eliminado {del_data.deletions} recursos", + occurrences=del_data.deletions, + affected_ips=[], + affected_users=[del_data.email], + first_seen=del_data.first_seen, + last_seen=del_data.last_seen, + recommendations=[ + f"Verificar urgentemente las eliminaciones de {del_data.email}", + "Comprobar si hay backups disponibles", + "Contactar al usuario para verificar la acción", + "Revisar sistema de permisos" + ] + )) + + # 4. ACCESOS DESDE MÚLTIPLES IPS (Cuenta comprometida) + multi_ip_query = select( + User.email, + func.count(func.distinct(AuditLog.ip_address)).label('ip_count'), + func.min(AuditLog.created_at).label('first_seen'), + func.max(AuditLog.created_at).label('last_seen') + ).join( + User, AuditLog.user_id == User.id + ).where( + and_( + AuditLog.tenant_id == current_tenant.id, + AuditLog.action.in_(['user.login', 'user.logout']), + AuditLog.created_at >= analysis_start + ) + ).group_by(User.email).having(func.count(func.distinct(AuditLog.ip_address)) >= 5) + + multi_ip_result = await db.execute(multi_ip_query) + multi_ip_users = multi_ip_result.all() + + for ip_data in multi_ip_users: + threats.append(SecurityThreatPattern( + type="account_compromise", + severity="medium", + description=f"Posible cuenta comprometida - {ip_data.email} accedió desde {ip_data.ip_count} IPs diferentes", + occurrences=ip_data.ip_count, + affected_ips=[], + affected_users=[ip_data.email], + first_seen=ip_data.first_seen, + last_seen=ip_data.last_seen, + recommendations=[ + f"Contactar a {ip_data.email} para verificar actividad", + "Forzar cambio de contraseña", + "Revisar ubicaciones de acceso", + "Considerar habilitar 2FA obligatorio" + ] + )) + + # CALCULAR ACCIONES CRÍTICAS (sincronizado con módulo de Auditoría) + critical_actions_count = 0 + + # Contar todos los login fallidos + login_failed_query = select(func.count(AuditLog.id)).where( + and_( + AuditLog.tenant_id == current_tenant.id, + AuditLog.action == 'user.login_failed', + AuditLog.created_at >= analysis_start + ) + ) + login_failed_result = await db.execute(login_failed_query) + critical_actions_count += login_failed_result.scalar() or 0 + + # Contar todas las eliminaciones (no solo masivas) + deletions_query = select(func.count(AuditLog.id)).where( + and_( + AuditLog.tenant_id == current_tenant.id, + AuditLog.action.like('%.delete'), + AuditLog.created_at >= analysis_start + ) + ) + deletions_result = await db.execute(deletions_query) + critical_actions_count += deletions_result.scalar() or 0 + + # Contar user.update con cambios sensibles (role, is_active, etc) + user_updates_query = select(AuditLog).where( + and_( + AuditLog.tenant_id == current_tenant.id, + AuditLog.action == 'user.update', + AuditLog.created_at >= analysis_start + ) + ) + user_updates_result = await db.execute(user_updates_query) + user_updates = user_updates_result.scalars().all() + + # Usar la misma función is_critical_user_update del módulo + for log in user_updates: + # Verificar si cambió algún campo sensible + if log.old_values and log.new_values: + sensitive_fields = ['role', 'is_active', 'is_superuser', 'permissions'] + for field in sensitive_fields: + if field in log.old_values and field in log.new_values: + if log.old_values[field] != log.new_values[field]: + critical_actions_count += 1 + break # Contar solo una vez por log + + # Calcular nivel de riesgo general + critical_count = sum(1 for t in threats if t.severity == "critical") + high_count = sum(1 for t in threats if t.severity == "high") + medium_count = sum(1 for t in threats if t.severity == "medium") + + if critical_count > 0: + overall_risk = "critical" + elif high_count >= 3: + overall_risk = "high" + elif high_count > 0 or medium_count >= 3: + overall_risk = "medium" + elif medium_count > 0 or len(threats) > 0: + overall_risk = "low" + else: + overall_risk = "safe" + + # Recomendaciones generales + recommended_actions = [] + if failed_login_attempts > 20: + recommended_actions.append("Implementar límite de intentos de login por IP") + if len(suspicious_ips) > 0: + recommended_actions.append(f"Bloquear {len(suspicious_ips)} IPs sospechosas identificadas") + if critical_actions_count > 50: + recommended_actions.append("Revisar políticas de permisos - demasiadas acciones críticas") + if len(threats) == 0: + recommended_actions.append("Sistema seguro - continuar monitoreando") + + return SecurityAnalysisResponse( + overall_risk_level=overall_risk, + total_threats_detected=len(threats), + threats=threats, + analysis_period_hours=hours, + generated_at=now, + failed_login_attempts=failed_login_attempts, + suspicious_ips_count=len(suspicious_ips), + critical_actions_count=critical_actions_count, + recommended_actions=recommended_actions + ) + + +@router.post("/security/action", response_model=SecurityActionResponse) +async def execute_security_action( + action: SecurityActionRequest, + current_user: User = Depends(require_auditor_role), + current_tenant: Tenant = Depends(get_current_tenant), + db: AsyncSession = Depends(get_db) +): + """ + Ejecutar acción de seguridad. + + **Permisos**: ADMIN, SUPPORT_MANAGER (solo ellos pueden ejecutar acciones) + + **Acciones disponibles**: + - `block_ip`: Bloquear IP temporalmente + - `notify_admin`: Notificar administradores + - `force_password_reset`: Forzar cambio de contraseña + - `disable_user`: Desactivar usuario temporalmente + + **Retorna**: Resultado de la acción + """ + # Verificar que solo ADMIN y SUPPORT_MANAGER puedan ejecutar acciones + if current_user.role not in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Solo administradores pueden ejecutar acciones de seguridad" + ) + + logger.info( + "Security action requested", + user_id=str(current_user.id), + action_type=action.action_type, + target=action.target + ) + + # Registrar la acción en auditoría + try: + await AuditService.log( + db=db, + tenant_id=current_tenant.id, + user_id=current_user.id, + action=f"security.{action.action_type}", + resource_type="security", + resource_id=None, + metadata={ + "target": action.target, + "reason": action.reason, + "duration_minutes": action.duration_minutes + } + ) + await db.commit() + except Exception as e: + logger.error("Failed to log security action", error=str(e)) + + # Por ahora, simular la ejecución (en producción conectar con firewall, email, etc.) + message = "" + success = True + + if action.action_type == "block_ip": + message = f"IP {action.target} bloqueada por {action.duration_minutes or 60} minutos. Razón: {action.reason}" + # TODO: Integrar con firewall/WAF + + elif action.action_type == "notify_admin": + message = f"Notificación enviada a administradores sobre: {action.reason}" + # TODO: Enviar email/Slack notification + + elif action.action_type == "force_password_reset": + message = f"Se forzará cambio de contraseña para {action.target}. Razón: {action.reason}" + # TODO: Marcar usuario para reset password + + elif action.action_type == "disable_user": + message = f"Usuario {action.target} desactivado temporalmente. Razón: {action.reason}" + # TODO: Desactivar usuario en BD + + else: + success = False + message = f"Tipo de acción no reconocida: {action.action_type}" + + return SecurityActionResponse( + success=success, + message=message, + action_id=None # TODO: Retornar ID del audit log creado + ) + + +@router.get("/security/incidents", response_model=SecurityIncidentListResponse) +async def get_security_incidents( + # Paginación + page: int = Query(default=1, ge=1, description="Número de página"), + per_page: int = Query(default=20, ge=1, le=100, description="Incidentes por página"), + + # Filtros + severity: Optional[str] = Query(None, description="Filtrar por severidad"), + status: Optional[str] = Query(None, description="Filtrar por estado"), + incident_type: Optional[str] = Query(None, description="Filtrar por tipo"), + search: Optional[str] = Query(None, description="Búsqueda en título o descripción"), + + # Multi-tenant (solo ADMIN/SUPPORT_MANAGER) + all_tenants: bool = Query(False, description="Ver incidentes de todos los tenants"), + + # Dependencies + current_user: User = Depends(require_auditor_role), + current_tenant: Tenant = Depends(get_current_tenant), + db: AsyncSession = Depends(get_db) +): + """ + Obtener incidentes de seguridad. + + Los incidentes se generan dinámicamente analizando logs de auditoría + para detectar patrones sospechosos y acciones críticas. + + **Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR + + **Retorna**: Lista paginada de incidentes de seguridad + """ + logger.info( + "Fetching security incidents", + user_id=str(current_user.id), + tenant_id=str(current_tenant.id), + filters={ + "severity": severity, + "status": status, + "type": incident_type, + "page": page, + "per_page": per_page + } + ) + + # Generar incidentes a partir de logs de auditoría + incidents = [] + now = datetime.now(timezone.utc) + + # Determinar rango de tiempo para análisis (últimos 7 días para mejor performance) + analysis_start = now - timedelta(days=7) + + # Construir query base + base_query = select(AuditLog).options( + selectinload(AuditLog.user) + ).where( + AuditLog.created_at >= analysis_start + ) + + # Aplicar filtro de tenant + if all_tenants and current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]: + # Ver incidentes de todos los tenants + pass + else: + base_query = base_query.where(AuditLog.tenant_id == current_tenant.id) + + # 1. DETECTAR ELIMINACIONES MASIVAS + deletion_query = base_query.where( + AuditLog.action.like('%.delete') + ).order_by(desc(AuditLog.created_at)) + + deletion_result = await db.execute(deletion_query) + deletion_logs = deletion_result.scalars().all() + + # Agrupar eliminaciones por usuario y fecha + deletion_groups = {} + for log in deletion_logs: + if not log.user: + continue + + key = f"{log.user.email}_{log.created_at.date()}" + if key not in deletion_groups: + deletion_groups[key] = { + 'user': log.user.email, + 'date': log.created_at.date(), + 'count': 0, + 'logs': [], + 'first_seen': log.created_at, + 'last_seen': log.created_at + } + + deletion_groups[key]['count'] += 1 + deletion_groups[key]['logs'].append(log) + if log.created_at < deletion_groups[key]['first_seen']: + deletion_groups[key]['first_seen'] = log.created_at + if log.created_at > deletion_groups[key]['last_seen']: + deletion_groups[key]['last_seen'] = log.created_at + + # Crear incidentes para eliminaciones masivas (>=3 eliminaciones) + for key, group in deletion_groups.items(): + if group['count'] >= 3: # Umbral para considerar "masivo" + severity = "critical" if group['count'] >= 10 else "high" if group['count'] >= 5 else "medium" + + incidents.append(SecurityIncidentResponse( + id=f"mass_del_{key.replace('_', '-')}", + title=f"Eliminaciones masivas - {group['user']}", + description=f"{group['user']} eliminó {group['count']} elementos el {group['date']}", + severity=severity, + status="active" if (now - group['last_seen']).days <= 1 else "resolved", + incident_type="mass_deletion", + affected_user=group['user'], + source_ip=group['logs'][0].ip_address, + evidence=[ + f"{log.action} - {log.resource_type} - {log.created_at.strftime('%H:%M:%S')}" + for log in group['logs'][:5] # Solo mostrar los primeros 5 + ], + metadata={ + "total_deletions": group['count'], + "resource_types": list(set(log.resource_type for log in group['logs'])), + "time_span_minutes": int((group['last_seen'] - group['first_seen']).total_seconds() / 60) + }, + created_at=group['first_seen'], + updated_at=group['last_seen'] + )) + + # 2. DETECTAR INTENTOS DE LOGIN FALLIDOS + failed_login_query = base_query.where( + AuditLog.action == 'user.login_failed' + ).order_by(desc(AuditLog.created_at)) + + failed_login_result = await db.execute(failed_login_query) + failed_login_logs = failed_login_result.scalars().all() + + # Agrupar por IP + ip_groups = {} + for log in failed_login_logs: + if not log.ip_address: + continue + + ip = str(log.ip_address) + if ip not in ip_groups: + ip_groups[ip] = { + 'count': 0, + 'logs': [], + 'first_seen': log.created_at, + 'last_seen': log.created_at, + 'users': set() + } + + ip_groups[ip]['count'] += 1 + ip_groups[ip]['logs'].append(log) + if log.created_at < ip_groups[ip]['first_seen']: + ip_groups[ip]['first_seen'] = log.created_at + if log.created_at > ip_groups[ip]['last_seen']: + ip_groups[ip]['last_seen'] = log.created_at + if log.user and log.user.email: + ip_groups[ip]['users'].add(log.user.email) + + # Crear incidentes para IPs con muchos fallos (>=5) + for ip, group in ip_groups.items(): + if group['count'] >= 5: + severity = "critical" if group['count'] >= 20 else "high" if group['count'] >= 10 else "medium" + + incidents.append(SecurityIncidentResponse( + id=f"brute_force_{ip.replace('.', '-')}", + title=f"Posible ataque de fuerza bruta desde {ip}", + description=f"Se detectaron {group['count']} intentos fallidos de login desde la IP {ip}", + severity=severity, + status="active" if (now - group['last_seen']).total_seconds() <= 86400 else "investigating", # 24 horas + incident_type="brute_force_attack", + affected_user=', '.join(list(group['users'])[:3]) if group['users'] else None, + source_ip=ip, + evidence=[ + f"Login fallido - {log.user.email if log.user else 'Unknown'} - {log.created_at.strftime('%H:%M:%S')}" + for log in group['logs'][:5] + ], + metadata={ + "total_attempts": group['count'], + "targeted_users": list(group['users']), + "time_span_hours": int((group['last_seen'] - group['first_seen']).total_seconds() / 3600) + }, + created_at=group['first_seen'], + updated_at=group['last_seen'] + )) + + # 3. DETECTAR CAMBIOS DE ROLES/PRIVILEGIOS + privilege_query = base_query.where( + and_( + AuditLog.action == 'user.update', + AuditLog.new_values.op('?')('role') + ) + ).order_by(desc(AuditLog.created_at)) + + privilege_result = await db.execute(privilege_query) + privilege_logs = privilege_result.scalars().all() + + for log in privilege_logs: + if not log.user or not log.new_values or 'role' not in log.new_values: + continue + + old_role = log.old_values.get('role') if log.old_values else 'Unknown' + new_role = log.new_values.get('role') + + # Solo crear incidente si es escalada de privilegios + role_hierarchy = {'CLIENT_USER': 1, 'CLIENT_ADMIN': 2, 'AGENT': 3, 'SUPPORT_MANAGER': 4, 'ADMIN': 5} + old_level = role_hierarchy.get(old_role, 0) + new_level = role_hierarchy.get(new_role, 0) + + if new_level > old_level: + incidents.append(SecurityIncidentResponse( + id=f"priv_esc_{log.id}", + title=f"Escalada de privilegios - {log.user.email}", + description=f"Usuario {log.user.email} cambió de rol {old_role} a {new_role}", + severity="high" if new_role in ['ADMIN', 'SUPPORT_MANAGER'] else "medium", + status="investigating", + incident_type="privilege_escalation", + affected_user=log.user.email, + source_ip=log.ip_address, + evidence=[ + f"Cambio de rol: {old_role} → {new_role} - {log.created_at.strftime('%Y-%m-%d %H:%M')}" + ], + metadata={ + "old_role": old_role, + "new_role": new_role, + "correlation_id": str(log.correlation_id) if log.correlation_id else None + }, + created_at=log.created_at, + updated_at=log.created_at + )) + + # Aplicar filtros de búsqueda + filtered_incidents = incidents + + if severity: + filtered_incidents = [i for i in filtered_incidents if i.severity == severity] + + if status: + filtered_incidents = [i for i in filtered_incidents if i.status == status] + + if incident_type: + filtered_incidents = [i for i in filtered_incidents if i.incident_type == incident_type] + + if search: + search_lower = search.lower() + filtered_incidents = [ + i for i in filtered_incidents + if search_lower in i.title.lower() or (i.description and search_lower in i.description.lower()) + ] + + # Ordenar por fecha de creación (más recientes primero) + filtered_incidents.sort(key=lambda x: x.created_at, reverse=True) + + # Aplicar paginación + total = len(filtered_incidents) + total_pages = (total + per_page - 1) // per_page + + start_idx = (page - 1) * per_page + end_idx = start_idx + per_page + paginated_incidents = filtered_incidents[start_idx:end_idx] + + return SecurityIncidentListResponse( + incidents=paginated_incidents, + total=total, + page=page, + per_page=per_page, + total_pages=total_pages + ) + diff --git a/backend/app/api/v1/endpoints/sla.py b/backend/app/api/v1/endpoints/sla.py index 2176dc2..b73935d 100644 --- a/backend/app/api/v1/endpoints/sla.py +++ b/backend/app/api/v1/endpoints/sla.py @@ -91,7 +91,7 @@ async def get_sla_dashboard( days=days ) - now = datetime.now(timezone.utc) + now = datetime.now(timezone.utc).replace(tzinfo=None) period_start = now - timedelta(days=days) # Usar func.now() para comparaciones en SQL (evita timezone issues) @@ -357,7 +357,7 @@ async def get_sla_violations( sla_type=sla_type ) - now = datetime.now(timezone.utc) + now = datetime.now(timezone.utc).replace(tzinfo=None) db_now = func.now() # Base query con carga de relaciones @@ -417,18 +417,16 @@ async def get_sla_violations( total_result = await db.execute(count_query) total = total_result.scalar() or 0 - # Aplicar paginación - query = query.order_by(desc(Ticket.created_at)).offset(skip).limit(limit) - + # Obtener todos los tickets sin paginación primero (los ordenaremos por tiempo vencido después) result = await db.execute(query) tickets = result.scalars().all() # Formatear response violations = [] for ticket in tickets: - # Asegurar que los datetimes de BD sean timezone-aware - sla_response_due = ticket.sla_response_due.replace(tzinfo=timezone.utc) if ticket.sla_response_due and ticket.sla_response_due.tzinfo is None else ticket.sla_response_due - sla_resolution_due = ticket.sla_resolution_due.replace(tzinfo=timezone.utc) if ticket.sla_resolution_due and ticket.sla_resolution_due.tzinfo is None else ticket.sla_resolution_due + # Todos los campos son timezone-naive (TIMESTAMP WITHOUT TIME ZONE) + sla_response_due = ticket.sla_response_due + sla_resolution_due = ticket.sla_resolution_due # Determinar tipo de violación response_violated = ticket.first_response_at is None and sla_response_due and now > sla_response_due @@ -479,10 +477,16 @@ async def get_sla_violations( resolved_at=ticket.resolved_at )) + # Ordenar por tiempo vencido (de mayor a menor) + violations.sort(key=lambda v: v.hours_overdue, reverse=True) + + # Aplicar paginación en Python + paginated_violations = violations[skip:skip + limit] + total_pages = (total + limit - 1) // limit return SLAViolationsListResponse( - violations=violations, + violations=paginated_violations, total=total, page=(skip // limit) + 1, per_page=limit, @@ -515,13 +519,16 @@ async def get_tickets_at_risk( threshold=threshold ) - now = datetime.now(timezone.utc) + now = datetime.now(timezone.utc).replace(tzinfo=None) db_now = func.now() threshold_decimal = threshold / 100.0 - # Query para tickets en riesgo + # Query para tickets en riesgo con relaciones precargadas # Un ticket está en riesgo si: (now - created_at) / (due_at - created_at) >= threshold - query = select(Ticket).where( + query = select(Ticket).options( + selectinload(Ticket.assigned_to_user), + selectinload(Ticket.category) + ).where( and_( Ticket.tenant_id == current_tenant.id, Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED]), @@ -576,14 +583,15 @@ async def get_tickets_at_risk( else: continue + # Normalizar created_at a timezone-naive para evitar errores de comparación + created_at = ticket.created_at.replace(tzinfo=None) if ticket.created_at.tzinfo else ticket.created_at + time_remaining = (due_at - now).total_seconds() / 3600 - total_time = (due_at - ticket.created_at).total_seconds() / 3600 + total_time = (due_at - created_at).total_seconds() / 3600 elapsed_time = total_time - time_remaining risk_percentage = (elapsed_time / total_time * 100) if total_time > 0 else 0 - # Cargar relaciones - await db.refresh(ticket, ['assigned_to', 'category']) - + # Las relaciones ya están cargadas por selectinload at_risk_tickets.append(SLATicketAtRisk( ticket=TicketBasicInfo( id=ticket.id, @@ -599,11 +607,11 @@ async def get_tickets_at_risk( sla_resolution_hours=ticket.category.sla_resolution_hours ) if ticket.category else None, assigned_to=UserBasicInfo( - id=ticket.assigned_to.id, - first_name=ticket.assigned_to.first_name, - last_name=ticket.assigned_to.last_name, - email=ticket.assigned_to.email - ) if ticket.assigned_to else None, + id=ticket.assigned_to_user.id, + first_name=ticket.assigned_to_user.first_name, + last_name=ticket.assigned_to_user.last_name, + email=ticket.assigned_to_user.email + ) if ticket.assigned_to_user else None, sla_type=sla_type, sla_due_at=due_at, time_remaining_hours=time_remaining, diff --git a/backend/app/api/v1/endpoints/tickets_backup.py b/backend/app/api/v1/endpoints/tickets_backup.py new file mode 100644 index 0000000..3f88df5 --- /dev/null +++ b/backend/app/api/v1/endpoints/tickets_backup.py @@ -0,0 +1,1072 @@ +""" +Tickets endpoints - ServiceManagerWeb +""" + +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File +from fastapi.responses import FileResponse +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from sqlalchemy.orm import selectinload +from typing import List, Optional +from datetime import datetime +from app.core.database import get_db +from app.api.deps import get_current_user, get_current_tenant +from app.models.ticket import Ticket, TicketStatus, TicketPriority +from app.models.user import User +from app.models.tenant import Tenant +from app.models.category import Category +from app.models.system import System +from app.models.comment import TicketComment +from app.models.attachment import TicketAttachment +from app.api.schemas.attachment import AttachmentResponse +from app.api.schemas.ticket import ( + TicketCreate, TicketUpdate, TicketResponse, + TicketCloseRequest, CommentCreate, CommentResponse +) +from app.core.file_handler import file_handler +from app.services.audit_service import AuditService +import uuid + +router = APIRouter() + +# =================================== +# TICKET ENDPOINTS +# =================================== + +@router.post("/", response_model=TicketResponse, status_code=status.HTTP_201_CREATED) +async def create_ticket( + ticket: TicketCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Crear un nuevo ticket + """ + # Retry logic para evitar race conditions en generación de ticket_number + max_retries = 3 + last_error = None + + for attempt in range(max_retries): + try: + # Generar número de ticket único basado en el máximo existente + result = await db.execute( + select(Ticket.ticket_number) + .where(Ticket.tenant_id == current_user.tenant_id) + .order_by(Ticket.ticket_number.desc()) + .limit(1) + ) + last_ticket_number = result.scalar_one_or_none() + + if last_ticket_number: + # Extraer el número del formato TK-XXXXXX + last_number = int(last_ticket_number.split('-')[1]) + next_number = last_number + 1 + else: + next_number = 1 + + ticket_number = f"TK-{next_number:06d}" + + # Convertir IDs de string a UUID si son proporcionados + category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None + system_uuid = uuid.UUID(ticket.affected_system_id) if ticket.affected_system_id else None + + # Validar categoría + category = None + if category_uuid: + category = await db.get(Category, category_uuid) + if not category: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"La categoría con ID {ticket.category_id} no existe." + ) + + # Validar sistema + if system_uuid: + system = await db.get(System, system_uuid) + if not system: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"El sistema con ID {ticket.affected_system_id} no existe." + ) + + # Calcular SLA deadlines basados en la categoría + from datetime import timedelta + sla_response_due = None + sla_resolution_due = None + assigned_to_user = None + + if category: + now = datetime.utcnow() + sla_response_due = now + timedelta(hours=category.sla_response_hours) + sla_resolution_due = now + timedelta(hours=category.sla_resolution_hours) + + # Auto-asignar si la categoría tiene configurado auto_assign_to + if category.auto_assign_to: + assigned_to_user = category.auto_assign_to + + db_ticket = Ticket( + id=uuid.uuid4(), + tenant_id=current_user.tenant_id, + ticket_number=ticket_number, + subject=ticket.subject, + description=ticket.description, + category_id=category_uuid, + affected_system_id=system_uuid, + priority=TicketPriority[ticket.priority.upper()], + created_by=current_user.id, + assigned_to=assigned_to_user, + status=TicketStatus.NEW, + sla_response_due=sla_response_due, + sla_resolution_due=sla_resolution_due, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow() + ) + + db.add(db_ticket) + await db.commit() + await db.refresh(db_ticket) + + # Registrar creación en auditoría + try: + await AuditService.log( + db=db, + tenant_id=current_user.tenant_id, + user_id=current_user.id, + action="ticket.create", + resource_type="ticket", + resource_id=db_ticket.id, + new_values={ + "ticket_number": db_ticket.ticket_number, + "subject": db_ticket.subject, + "priority": db_ticket.priority.value, + "status": db_ticket.status.value + } + ) + await db.commit() + except Exception as e: + # No fallar si falla el audit log + pass + + # ✅ Éxito - retornar ticket creado + return { + "id": str(db_ticket.id), + "ticket_number": db_ticket.ticket_number, + "subject": db_ticket.subject, + "title": db_ticket.subject, + "description": db_ticket.description, + "status": db_ticket.status.value, + "priority": db_ticket.priority.value, + "category_id": str(db_ticket.category_id) if db_ticket.category_id else None, + "affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None, + "created_by": str(db_ticket.created_by), + "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None, + "created_at": db_ticket.created_at, + "updated_at": db_ticket.updated_at + } + + except ValueError as e: + await db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid UUID format: {str(e)}" + ) + except HTTPException: + # Re-lanzar HTTPExceptions directamente + await db.rollback() + raise + except Exception as e: + await db.rollback() + last_error = e + + # Si es un error de llave duplicada, reintentar + if "duplicate key" in str(e).lower() and "ticket_number" in str(e).lower(): + if attempt < max_retries - 1: + continue # Reintentar + + # Para cualquier otro error, fallar inmediatamente + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Error creating ticket: {str(e)}" + ) + + # Si llegamos aquí después de todos los reintentos + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"No se pudo crear el ticket después de {max_retries} intentos: {str(last_error)}" + ) + + +@router.get("/", response_model=List[TicketResponse]) +async def get_tickets( + skip: int = 0, + limit: int = 100, + status: Optional[str] = None, + priority: Optional[str] = None, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Obtener tickets con filtros opcionales + Roles ADMIN/SUPPORT_MANAGER/AGENT: Ven todos los tickets del tenant + Roles CLIENT_USER/CLIENT_ADMIN: Solo ven sus propios tickets + + Filtros disponibles: + - status: NEW, IN_PROGRESS, WAITING_CUSTOMER, RESOLVED, CLOSED, REOPENED + - priority: LOW, MEDIUM, HIGH, URGENT + """ + # Construir query base filtrado por tenant + query = select(Ticket).where( + Ticket.tenant_id == current_user.tenant_id + ) + + # Si es cliente, solo puede ver sus propios tickets + if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]: + query = query.where(Ticket.created_by == current_user.id) + + # Filtro por estado + if status: + try: + status_enum = TicketStatus[status.upper()] + query = query.where(Ticket.status == status_enum) + except KeyError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid status: {status}. Valid values: NEW, IN_PROGRESS, WAITING_CUSTOMER, RESOLVED, CLOSED, REOPENED" + ) + + # Filtro por prioridad + if priority: + try: + priority_enum = TicketPriority[priority.upper()] + query = query.where(Ticket.priority == priority_enum) + except KeyError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid priority: {priority}. Valid values: LOW, MEDIUM, HIGH, URGENT" + ) + + query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit) + + result = await db.execute(query) + tickets = result.scalars().all() + + # ✅ CORREGIDO: Usar affected_system_id y agregar campos SLA + return [ + { + "id": str(t.id), + "ticket_number": t.ticket_number, + "subject": t.subject, + "title": t.subject, + "description": t.description, + "status": t.status.value, + "priority": t.priority.value, + "category_id": str(t.category_id) if t.category_id else None, + "affected_system_id": str(t.affected_system_id) if t.affected_system_id else None, + "created_by": str(t.created_by), + "assigned_to": str(t.assigned_to) if t.assigned_to else None, + "created_at": t.created_at, + "updated_at": t.updated_at, + "sla_response_due": t.sla_response_due, + "sla_resolution_due": t.sla_resolution_due, + "first_response_at": t.first_response_at, + "resolved_at": t.resolved_at + } + for t in tickets + ] + + +@router.get("/admin/all", response_model=List[dict]) +async def get_all_tickets_admin( + skip: int = 0, + limit: int = 100, + status_filter: Optional[str] = None, + priority_filter: Optional[str] = None, + tenant_id_filter: Optional[str] = None, + category_filter: Optional[str] = None, + assigned_to_filter: Optional[str] = None, + search: Optional[str] = None, + date_from: Optional[str] = None, + date_to: Optional[str] = None, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Obtener todos los tickets de todos los tenants (solo para administradores) + Incluye información del tenant y usuario que creó el ticket + Filtros: estado, prioridad, tenant, categoría, asignado a, búsqueda de texto y fechas + """ + # Verificar que el usuario sea administrador + if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="No tienes permisos para acceder a esta función" + ) + + # Query base con joins para obtener información del tenant y usuario creador + query = select(Ticket, Tenant, User).join( + Tenant, Ticket.tenant_id == Tenant.id + ).join( + User, Ticket.created_by == User.id + ) + + # Aplicar filtros + if status_filter: + try: + status_enum = TicketStatus[status_filter.upper()] + query = query.where(Ticket.status == status_enum) + except KeyError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid status: {status_filter}" + ) + + if priority_filter: + try: + priority_enum = TicketPriority[priority_filter.upper()] + query = query.where(Ticket.priority == priority_enum) + except KeyError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid priority: {priority_filter}" + ) + + if tenant_id_filter: + try: + tenant_uuid = uuid.UUID(tenant_id_filter) + query = query.where(Ticket.tenant_id == tenant_uuid) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid tenant ID format" + ) + + if category_filter: + try: + category_uuid = uuid.UUID(category_filter) + query = query.where(Ticket.category_id == category_uuid) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid category ID format" + ) + + if assigned_to_filter: + try: + assigned_uuid = uuid.UUID(assigned_to_filter) + query = query.where(Ticket.assigned_to == assigned_uuid) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid assigned user ID format" + ) + + if search: + # Búsqueda de texto en subject y description + search_pattern = f"%{search}%" + query = query.where( + (Ticket.subject.ilike(search_pattern)) | + (Ticket.description.ilike(search_pattern)) + ) + + if date_from: + try: + from datetime import datetime + date_from_parsed = datetime.fromisoformat(date_from) + query = query.where(Ticket.created_at >= date_from_parsed) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid date_from format. Use YYYY-MM-DD" + ) + + if date_to: + try: + from datetime import datetime, timedelta + # Agregar 1 día para incluir todo el día final + date_to_parsed = datetime.fromisoformat(date_to) + timedelta(days=1) + query = query.where(Ticket.created_at < date_to_parsed) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid date_to format. Use YYYY-MM-DD" + ) + + query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit) + + result = await db.execute(query) + rows = result.all() + + return [ + { + "id": str(ticket.id), + "ticket_number": ticket.ticket_number, + "subject": ticket.subject, + "title": ticket.subject, + "description": ticket.description, + "status": ticket.status.value, + "priority": ticket.priority.value, + "category_id": str(ticket.category_id) if ticket.category_id else None, + "affected_system_id": str(ticket.affected_system_id) if ticket.affected_system_id else None, + "created_by": str(ticket.created_by), + "assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None, + "created_at": ticket.created_at, + "updated_at": ticket.updated_at, + # Información del tenant/cliente + "tenant": { + "id": str(tenant.id), + "name": tenant.name, + "slug": tenant.slug, + "contact_email": tenant.contact_email + }, + # Información del usuario creador + "created_by_user": { + "id": str(user.id), + "email": user.email, + "first_name": user.first_name, + "last_name": user.last_name, + "role": user.role.value if hasattr(user.role, 'value') else str(user.role) + } + } + for ticket, tenant, user in rows + ] + + +@router.get("/{ticket_id}", response_model=TicketResponse) +async def get_ticket( + ticket_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Obtener un ticket específico + Roles ADMIN/SUPPORT_MANAGER/AGENT: Pueden ver todos los tickets del tenant + Roles CLIENT_USER/CLIENT_ADMIN: Solo pueden ver sus propios tickets + """ + try: + ticket_uuid = uuid.UUID(ticket_id) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid ticket ID format" + ) + + # Construir query basado en el rol del usuario + query = select(Ticket).where( + Ticket.id == ticket_uuid, + Ticket.tenant_id == current_user.tenant_id + ) + + # Si es cliente, solo puede ver sus propios tickets + if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]: + query = query.where(Ticket.created_by == current_user.id) + + result = await db.execute(query) + ticket = result.scalars().first() + + if not ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Ticket {ticket_id} not found" + ) + + # ✅ CORREGIDO: Usar affected_system_id + campos SLA + return { + "id": str(ticket.id), + "ticket_number": ticket.ticket_number, + "subject": ticket.subject, + "title": ticket.subject, + "description": ticket.description, + "status": ticket.status.value, + "priority": ticket.priority.value, + "category_id": str(ticket.category_id) if ticket.category_id else None, + "affected_system_id": str(ticket.affected_system_id) if ticket.affected_system_id else None, + "created_by": str(ticket.created_by), + "assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None, + "created_at": ticket.created_at, + "updated_at": ticket.updated_at, + "first_response_at": ticket.first_response_at, + "resolved_at": ticket.resolved_at, + "sla_response_due": ticket.sla_response_due, + "sla_resolution_due": ticket.sla_resolution_due, + } + + +@router.patch("/{ticket_id}", response_model=TicketResponse) +async def update_ticket( + ticket_id: str, + ticket_update: TicketUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Actualizar un ticket + Roles ADMIN/SUPPORT_MANAGER/AGENT: Pueden actualizar cualquier ticket del tenant + Roles CLIENT_USER/CLIENT_ADMIN: Solo pueden actualizar sus propios tickets + """ + try: + ticket_uuid = uuid.UUID(ticket_id) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid ticket ID format" + ) + + # Construir query basado en el rol del usuario + query = select(Ticket).where( + Ticket.id == ticket_uuid, + Ticket.tenant_id == current_user.tenant_id + ) + + # Si es cliente, solo puede actualizar sus propios tickets + if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]: + query = query.where(Ticket.created_by == current_user.id) + + result = await db.execute(query) + db_ticket = result.scalars().first() + + if not db_ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Ticket {ticket_id} not found" + ) + + # Guardar valores anteriores para audit + old_values = { + "subject": db_ticket.subject, + "description": db_ticket.description, + "status": db_ticket.status.value, + "priority": db_ticket.priority.value, + "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None + } + + try: + update_data = ticket_update.dict(exclude_unset=True) + + for field, value in update_data.items(): + if field == "status" and value: + new_status = TicketStatus[value.upper()] + setattr(db_ticket, field, new_status) + # Registrar timestamp de resolución + if new_status in (TicketStatus.RESOLVED, TicketStatus.CLOSED): + if db_ticket.resolved_at is None: + db_ticket.resolved_at = datetime.utcnow() + # Si reabre el ticket, limpiar resolved_at + elif new_status in (TicketStatus.NEW, TicketStatus.IN_PROGRESS, TicketStatus.WAITING_CUSTOMER): + db_ticket.resolved_at = None + elif field == "priority" and value: + setattr(db_ticket, field, TicketPriority[value.upper()]) + elif field == "assigned_to" and value: + setattr(db_ticket, field, uuid.UUID(value)) + else: + setattr(db_ticket, field, value) + + db_ticket.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(db_ticket) + + # Registrar actualización en auditoría + try: + new_values = { + "subject": db_ticket.subject, + "description": db_ticket.description, + "status": db_ticket.status.value, + "priority": db_ticket.priority.value, + "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None + } + + # Si cambió assigned_to, registrar como acción de asignación + action = "ticket.assign" if old_values["assigned_to"] != new_values["assigned_to"] else "ticket.update" + + await AuditService.log( + db=db, + tenant_id=current_user.tenant_id, + user_id=current_user.id, + action=action, + resource_type="ticket", + resource_id=db_ticket.id, + old_values=old_values, + new_values=new_values + ) + await db.commit() + except Exception as e: + # No fallar si falla el audit log + pass + + # ✅ CORREGIDO: Usar affected_system_id + return { + "id": str(db_ticket.id), + "ticket_number": db_ticket.ticket_number, + "subject": db_ticket.subject, + "title": db_ticket.subject, + "description": db_ticket.description, + "status": db_ticket.status.value, + "priority": db_ticket.priority.value, + "category_id": str(db_ticket.category_id) if db_ticket.category_id else None, + "affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None, # ✅ CORREGIDO + "created_by": str(db_ticket.created_by), + "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None, + "created_at": db_ticket.created_at, + "updated_at": db_ticket.updated_at + } + + except Exception as e: + await db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Error updating ticket: {str(e)}" + ) + + +@router.patch("/{ticket_id}/close", response_model=TicketResponse) +async def close_ticket( + ticket_id: str, + close_request: TicketCloseRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Cerrar un ticket + Roles ADMIN/SUPPORT_MANAGER/AGENT: Pueden cerrar cualquier ticket del tenant + Roles CLIENT_USER/CLIENT_ADMIN: Solo pueden cerrar sus propios tickets + """ + try: + ticket_uuid = uuid.UUID(ticket_id) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid ticket ID format" + ) + + # Construir query basado en el rol del usuario + query = select(Ticket).where( + Ticket.id == ticket_uuid, + Ticket.tenant_id == current_user.tenant_id + ) + + # Si es cliente, solo puede cerrar sus propios tickets + if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]: + query = query.where(Ticket.created_by == current_user.id) + + result = await db.execute(query) + db_ticket = result.scalars().first() + + if not db_ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Ticket {ticket_id} not found" + ) + + try: + db_ticket.status = TicketStatus.CLOSED + if db_ticket.resolved_at is None: + db_ticket.resolved_at = datetime.utcnow() + db_ticket.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(db_ticket) + + return { + "id": str(db_ticket.id), + "ticket_number": db_ticket.ticket_number, + "subject": db_ticket.subject, + "title": db_ticket.subject, + "description": db_ticket.description, + "status": db_ticket.status.value, + "priority": db_ticket.priority.value, + "category_id": str(db_ticket.category_id) if db_ticket.category_id else None, + "affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None, + "created_by": str(db_ticket.created_by), + "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None, + "created_at": db_ticket.created_at, + "updated_at": db_ticket.updated_at, + "resolved_at": db_ticket.resolved_at + } + + except Exception as e: + await db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Error closing ticket: {str(e)}" + ) + + +## =================================== +# COMMENT ENDPOINTS +# =================================== + + +@router.get("/{ticket_id}/comments", response_model=List[CommentResponse]) +async def get_ticket_comments( + ticket_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Obtener comentarios de un ticket + """ + try: + ticket_uuid = uuid.UUID(ticket_id) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid ticket ID format" + ) + + # Verificar que el ticket existe y el usuario tiene acceso + ticket_query = select(Ticket).where( + Ticket.id == ticket_uuid, + Ticket.tenant_id == current_user.tenant_id + ) + ticket_result = await db.execute(ticket_query) + ticket = ticket_result.scalars().first() + + if not ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Ticket {ticket_id} not found" + ) + + # Obtener comentarios + comments_query = select(TicketComment, User).join( + User, TicketComment.author_id == User.id + ).where( + TicketComment.ticket_id == ticket_uuid + ).order_by(TicketComment.created_at.asc()) + + result = await db.execute(comments_query) + comments_with_users = result.all() + + # Formatear respuesta + return [ + { + "id": str(comment.id), + "ticket_id": str(comment.ticket_id), + "author_id": str(comment.author_id), + "author_name": f"{user.first_name} {user.last_name}", + "content": comment.content, + "is_internal": comment.is_internal, + "created_at": comment.created_at, + "updated_at": comment.updated_at + } + for comment, user in comments_with_users + ] + + +@router.post("/{ticket_id}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED) +async def create_comment( + ticket_id: str, + comment: CommentCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Agregar un comentario a un ticket + """ + try: + ticket_uuid = uuid.UUID(ticket_id) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid ticket ID format" + ) + + # Verificar que el ticket existe y el usuario tiene acceso + ticket_query = select(Ticket).where( + Ticket.id == ticket_uuid, + Ticket.tenant_id == current_user.tenant_id + ) + ticket_result = await db.execute(ticket_query) + ticket_obj = ticket_result.scalars().first() + + if not ticket_obj: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Ticket {ticket_id} not found" + ) + + # Crear comentario + new_comment = TicketComment( + id=uuid.uuid4(), + ticket_id=ticket_uuid, + author_id=current_user.id, + content=comment.content, + is_internal=comment.is_internal, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow() + ) + + db.add(new_comment) + + # Registrar primera respuesta de staff si aún no se ha hecho + staff_roles = ["ADMIN", "SUPPORT_MANAGER", "AGENT"] + if ( + current_user.role in staff_roles + and not comment.is_internal + and ticket_obj.first_response_at is None + ): + ticket_obj.first_response_at = datetime.utcnow() + + # Actualizar el ticket updated_at + ticket_obj.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(new_comment) + + # Retornar con el nombre del autor + return { + "id": str(new_comment.id), + "ticket_id": str(new_comment.ticket_id), + "author_id": str(new_comment.author_id), + "author_name": f"{current_user.first_name} {current_user.last_name}", + "content": new_comment.content, + "is_internal": new_comment.is_internal, + "created_at": new_comment.created_at, + "updated_at": new_comment.updated_at + } +@router.delete("/{ticket_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_ticket( + ticket_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Eliminar un ticket (solo admin/manager) + """ + try: + ticket_uuid = uuid.UUID(ticket_id) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid ticket ID format" + ) + + query = select(Ticket).where( + Ticket.id == ticket_uuid, + Ticket.tenant_id == current_user.tenant_id + ) + + result = await db.execute(query) + db_ticket = result.scalars().first() + + if not db_ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Ticket {ticket_id} not found" + ) + + # Guardar datos del ticket antes de eliminar para audit + old_values = { + "ticket_number": db_ticket.ticket_number, + "subject": db_ticket.subject, + "status": db_ticket.status.value, + "priority": db_ticket.priority.value + } + + await db.delete(db_ticket) + await db.commit() + + # Registrar eliminación en auditoría + try: + await AuditService.log( + db=db, + tenant_id=current_user.tenant_id, + user_id=current_user.id, + action="ticket.delete", + resource_type="ticket", + resource_id=ticket_uuid, + old_values=old_values + ) + await db.commit() + except Exception as e: + # No fallar si falla el audit log + pass + + return {"message": "Ticket deleted successfully"} + +# =================================== +# =================================== +# ATTACHMENT ENDPOINTS +# =================================== + +@router.get("/{ticket_id}/attachments", response_model=List[AttachmentResponse]) +async def get_ticket_attachments( + ticket_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), + current_tenant: Tenant = Depends(get_current_tenant) +): + """Obtener adjuntos de un ticket""" + try: + ticket_uuid = uuid.UUID(ticket_id) + except ValueError: + raise HTTPException(status_code=400, detail="ID de ticket inválido") + + # Verificar que el ticket existe y pertenece al tenant + result = await db.execute( + select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_tenant.id) + ) + ticket = result.scalar_one_or_none() + + if not ticket: + raise HTTPException(status_code=404, detail="Ticket no encontrado") + + # Obtener attachments + result = await db.execute( + select(TicketAttachment) + .where(TicketAttachment.ticket_id == ticket_uuid) + .options(selectinload(TicketAttachment.uploaded_by_user)) + .order_by(TicketAttachment.created_at.desc()) + ) + attachments = result.scalars().all() + + # Construir respuesta + response = [] + for att in attachments: + response.append(AttachmentResponse( + id=att.id, + ticket_id=att.ticket_id, + comment_id=att.comment_id, + uploaded_by=att.uploaded_by, + filename=att.filename, + original_filename=att.original_filename, + mime_type=att.mime_type, + file_size=att.file_size, + file_path=att.file_path, + uploaded_by_name=f"{att.uploaded_by_user.first_name} {att.uploaded_by_user.last_name}" if att.uploaded_by_user else "Unknown", + created_at=att.created_at, + download_url=f"/api/v1/tickets/{ticket_id}/attachments/{att.id}/download" + )) + + return response + + +@router.post("/{ticket_id}/attachments", status_code=status.HTTP_201_CREATED) +async def upload_attachment( + ticket_id: str, + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), + current_tenant: Tenant = Depends(get_current_tenant) +): + """Subir un archivo adjunto a un ticket""" + try: + ticket_uuid = uuid.UUID(ticket_id) + except ValueError: + raise HTTPException(status_code=400, detail="ID de ticket inválido") + + # Verificar ticket + result = await db.execute( + select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_tenant.id) + ) + ticket = result.scalar_one_or_none() + + if not ticket: + raise HTTPException(status_code=404, detail="Ticket no encontrado") + + # Guardar archivo + file_metadata = await file_handler.save_upload(file, current_tenant.id, ticket_uuid) + + # Crear registro en BD + attachment = TicketAttachment( + id=uuid.uuid4(), + ticket_id=ticket_uuid, + uploaded_by=current_user.id, + filename=file_metadata["filename"], + original_filename=file_metadata["original_filename"], + mime_type=file_metadata["mime_type"], + file_size=file_metadata["file_size"], + file_path=file_metadata["file_path"], + md5_hash=file_metadata["md5_hash"], + sha256_hash=file_metadata["sha256_hash"], + created_at=datetime.utcnow() + ) + + db.add(attachment) + await db.commit() + await db.refresh(attachment, ["uploaded_by_user"]) + + return { + "success": True, + "message": "Archivo subido exitosamente", + "data": AttachmentResponse( + id=attachment.id, + ticket_id=attachment.ticket_id, + comment_id=attachment.comment_id, + uploaded_by=attachment.uploaded_by, + filename=attachment.filename, + original_filename=attachment.original_filename, + mime_type=attachment.mime_type, + file_size=attachment.file_size, + file_path=attachment.file_path, + uploaded_by_name=f"{attachment.uploaded_by_user.first_name} {attachment.uploaded_by_user.last_name}", + created_at=attachment.created_at, + download_url=f"/api/v1/tickets/{ticket_id}/attachments/{attachment.id}/download" + ) + } + + +@router.get("/{ticket_id}/attachments/{attachment_id}/download") +async def download_attachment( + ticket_id: str, + attachment_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), + current_tenant: Tenant = Depends(get_current_tenant) +): + """Descargar un archivo adjunto""" + import logging + logger = logging.getLogger(__name__) + + logger.info(f"Download request - ticket_id: {ticket_id}, attachment_id: {attachment_id}") + + try: + ticket_uuid = uuid.UUID(ticket_id) + attachment_uuid = uuid.UUID(attachment_id) + except ValueError: + logger.error(f"Invalid UUID format - ticket_id: {ticket_id}, attachment_id: {attachment_id}") + raise HTTPException(status_code=400, detail="ID inválido") + + # Verificar ticket + result = await db.execute( + select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_tenant.id) + ) + ticket = result.scalar_one_or_none() + + if not ticket: + logger.error(f"Ticket not found - ticket_id: {ticket_id}") + raise HTTPException(status_code=404, detail="Ticket no encontrado") + + # Obtener attachment + result = await db.execute( + select(TicketAttachment) + .where(TicketAttachment.id == attachment_uuid, TicketAttachment.ticket_id == ticket_uuid) + ) + attachment = result.scalar_one_or_none() + + if not attachment: + logger.error(f"Attachment not found - attachment_id: {attachment_id}") + raise HTTPException(status_code=404, detail="Adjunto no encontrado") + + logger.info(f"Attachment found - file_path: {attachment.file_path}, original_filename: {attachment.original_filename}") + + # Obtener path del archivo + try: + file_path = file_handler.get_file_path(attachment.file_path) + logger.info(f"Absolute file path: {file_path}") + + if not file_path.exists(): + logger.error(f"File does not exist at path: {file_path}") + raise HTTPException(status_code=404, detail="Archivo no encontrado en el sistema") + + except Exception as e: + logger.error(f"Error getting file path: {str(e)}") + raise + + # Retornar archivo + logger.info(f"Returning file: {attachment.original_filename}") + return FileResponse( + path=file_path, + filename=attachment.original_filename, + media_type=attachment.mime_type + ) \ No newline at end of file diff --git a/backend/scripts/README_SECURITY_TESTS.md b/backend/scripts/README_SECURITY_TESTS.md new file mode 100644 index 0000000..aec8b8d --- /dev/null +++ b/backend/scripts/README_SECURITY_TESTS.md @@ -0,0 +1,193 @@ +# Scripts de Prueba de Seguridad + +Scripts para generar datos de prueba para el análisis de seguridad. + +## 📋 Scripts Disponibles + +### 1. `generate_security_test_data.py` + +Genera un conjunto completo de logs de auditoría para probar todas las funcionalidades del análisis de seguridad. + +#### Uso + +```bash +# Asegúrate de estar en el entorno virtual +cd backend +python scripts/generate_security_test_data.py +``` + +#### Qué Genera + +- **25 intentos fallidos de login** → Amenaza HIGH de fuerza bruta +- **55 eliminaciones masivas** → Amenaza CRITICAL +- **5 cambios de privilegios** → Amenaza HIGH de escalación de privilegios +- **20 logs normales** → Actividad regular para contexto + +#### Limpiar Datos de Prueba + +```bash +python scripts/generate_security_test_data.py cleanup +``` + +Esto eliminará **TODOS** los logs de auditoría de las últimas 24 horas. + +--- + +## 🎯 Escenarios de Prueba + +### Escenario 1: Sistema Limpio (Sin Amenazas) + +```bash +# Limpiar todos los logs +python scripts/generate_security_test_data.py cleanup +``` + +**Resultado esperado:** +- Dashboard con todos los contadores en 0 +- Tab "Crítico" vacío +- Mensaje: "Sistema Seguro" + +--- + +### Escenario 2: Solo Amenazas Leves + +Modifica el script para generar solo 6 intentos fallidos (MEDIUM severity): + +```python +# En generate_security_test_data.py, línea ~70 +for i in range(6): # Cambiar de 25 a 6 +``` + +**Resultado esperado:** +- 1 amenaza MEDIUM en tab correspondiente +- Tab "Crítico" vacío +- Nivel de riesgo: LOW o MEDIUM + +--- + +### Escenario 3: Amenazas Críticas + +Ejecuta el script completo: + +```bash +python scripts/generate_security_test_data.py +``` + +**Resultado esperado:** +- 1 amenaza CRÍTICA (eliminaciones masivas) +- 2 amenazas HIGH (login fallidos + privilegios) +- Tab "Crítico" con 1 amenaza +- Nivel de riesgo: CRITICAL + +--- + +## 🔒 Umbrales de Detección + +| Tipo de Amenaza | Umbral Detección | Severidades | +|-----------------|------------------|-------------| +| **Fuerza Bruta** | ≥5 intentos fallidos | MEDIUM (5-19), HIGH (≥20) | +| **Eliminaciones Masivas** | ≥10 eliminaciones | HIGH (10-49), **CRITICAL (≥50)** | +| **Cambios de Privilegios** | ≥3 cambios de rol | HIGH (siempre) | + +--- + +## 🧪 Verificar Resultados + +1. **Accede al panel de auditoría**: http://localhost:3001/audit/security + +2. **Verifica los contadores del dashboard:** + - Nivel de Riesgo + - Amenazas Detectadas + - Intentos Fallidos + - IPs Sospechosas + - Acciones Críticas + +3. **Prueba los tabs:** + - Todas: Debe mostrar amenazas activas + - Crítico: Solo amenazas critical (si hay) + - High: Amenazas de alta severidad + - Medium: Amenazas de severidad media + - Low: Amenazas de baja severidad + - Resueltas: Amenazas marcadas como resueltas + +4. **Prueba la búsqueda:** + - Busca por IP: `192.168.1.100` + - Busca por descripción: `intentos fallidos` + - Busca por tipo: `brute_force` + +5. **Prueba los filtros:** + - Filtra por tipo de amenaza + - Combina búsqueda + filtro + +6. **Prueba las acciones:** + - Selecciona múltiples amenazas + - Resuelve en batch + - Marca como resuelta individualmente + - Reabre amenazas resueltas + +--- + +## ⚠️ Advertencias + +- **NO ejecutar en producción**: Estos scripts son SOLO para desarrollo/testing +- **Los datos son ficticios**: IPs, usuarios y acciones son simulados +- **Cleanup elimina TODO**: El comando cleanup elimina TODOS los logs de las últimas 24h, no solo los de prueba + +--- + +## 🐛 Troubleshooting + +### Error: "No se encontró ningún tenant" +```bash +# Ejecuta las migraciones +cd backend +alembic upgrade head +``` + +### Error: "No se encontró ningún usuario" +```bash +# Crea un usuario de prueba +python scripts/create_test_user.py +``` + +### La página no muestra amenazas +- Verifica que el backend esté corriendo: `uvicorn app.main:app --reload` +- Revisa la consola del navegador para errores +- Verifica que los logs se crearon: `SELECT COUNT(*) FROM audit_logs WHERE created_at >= NOW() - INTERVAL '24 hours';` + +### Las fechas no son de hoy +- Los logs se crean con timestamps aleatorios en las últimas 24h +- Si todos tienen la misma fecha, es porque se generaron en el mismo segundo (normal) + +--- + +## 📝 Personalizar Generación + +Para crear escenarios personalizados, edita `generate_security_test_data.py`: + +```python +# Cambiar cantidad de intentos fallidos +for i in range(50): # Más intentos = mayor severidad + +# Cambiar IPs sospechosas +suspicious_ips = ["1.2.3.4", "5.6.7.8"] + +# Cambiar período temporal +time_offset = timedelta(hours=12) # Todos en las últimas 12h + +# Agregar más tipos de amenazas +# Agrega nuevos bloques de generación siguiendo el patrón +``` + +--- + +## 🚀 Flujo Recomendado de Prueba + +1. **Limpia el sistema**: `python scripts/generate_security_test_data.py cleanup` +2. **Verifica sistema limpio**: Accede a la página, debe estar vacía +3. **Genera datos completos**: `python scripts/generate_security_test_data.py` +4. **Prueba todas las funcionalidades**: tabs, filtros, búsqueda, acciones +5. **Marca algunas como resueltas**: Prueba el flujo de resolución +6. **Verifica tab "Resueltas"**: Confirma que aparecen ahí +7. **Reabre algunas**: Prueba el flujo de reapertura +8. **Limpia al finalizar**: `python scripts/generate_security_test_data.py cleanup` diff --git a/backend/scripts/generate_security_test_data.py b/backend/scripts/generate_security_test_data.py new file mode 100644 index 0000000..b364221 --- /dev/null +++ b/backend/scripts/generate_security_test_data.py @@ -0,0 +1,240 @@ +""" +Script para generar datos de prueba de seguridad en logs de auditoría. +Esto permite probar la funcionalidad de análisis de seguridad con diferentes tipos de amenazas. +""" +import asyncio +import sys +from pathlib import Path +from datetime import datetime, timedelta, timezone +import uuid +import random + +# Agregar el directorio raíz al path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.core.database import AsyncSessionLocal +from app.models.audit import AuditLog +from app.models.user import User +from app.models.tenant import Tenant + + +async def generate_test_data(): + """Genera logs de auditoría de prueba para análisis de seguridad.""" + async with AsyncSessionLocal() as db: + # Obtener tenant y usuarios de prueba + tenant_result = await db.execute(select(Tenant).limit(1)) + tenant = tenant_result.scalar_one_or_none() + + if not tenant: + print("❌ No se encontró ningún tenant. Ejecuta las migraciones primero.") + return + + user_result = await db.execute(select(User).where(User.tenant_id == tenant.id).limit(1)) + user = user_result.scalar_one_or_none() + + if not user: + print("❌ No se encontró ningún usuario. Crea un usuario primero.") + return + + print(f"✅ Usando tenant: {tenant.name}") + print(f"✅ Usando usuario: {user.email}") + print() + + now = datetime.now(timezone.utc) + + # IPs de prueba + suspicious_ips = [ + "192.168.1.100", + "10.0.0.50", + "172.16.0.10", + "203.0.113.42", + "198.51.100.88" + ] + + logs_created = 0 + + # ============================================ + # 1. GENERAR INTENTOS FALLIDOS DE LOGIN (Fuerza Bruta) + # ============================================ + print("🔐 Generando intentos fallidos de login...") + + # Generar 25 intentos fallidos (esto hará que sea HIGH severity) + for i in range(25): + time_offset = timedelta(hours=random.randint(0, 23), minutes=random.randint(0, 59)) + log = AuditLog( + id=uuid.uuid4(), + tenant_id=tenant.id, + user_id=user.id, + action="user.login_failed", + resource_type="auth", + resource_id=None, + ip_address=random.choice(suspicious_ips), + user_agent="Mozilla/5.0 (Test Browser)", + metadata={"reason": "invalid_credentials", "username": f"test_user_{i}"}, + created_at=now - time_offset + ) + db.add(log) + logs_created += 1 + + print(f" ✓ Creados {25} intentos fallidos de login (HIGH severity)") + + # ============================================ + # 2. GENERAR ELIMINACIONES MASIVAS (CRITICAL) + # ============================================ + print("🗑️ Generando eliminaciones masivas...") + + resources = ["ticket", "comment", "attachment", "category", "user"] + + # Generar 55 eliminaciones (esto hará que sea CRITICAL severity) + for i in range(55): + time_offset = timedelta(hours=random.randint(0, 23), minutes=random.randint(0, 59)) + resource = random.choice(resources) + log = AuditLog( + id=uuid.uuid4(), + tenant_id=tenant.id, + user_id=user.id, + action=f"{resource}.delete", + resource_type=resource, + resource_id=uuid.uuid4(), + ip_address=random.choice(suspicious_ips), + user_agent="Mozilla/5.0 (Test Browser)", + metadata={"deleted_by": user.email}, + created_at=now - time_offset + ) + db.add(log) + logs_created += 1 + + print(f" ✓ Creadas {55} eliminaciones masivas (CRITICAL severity)") + + # ============================================ + # 3. GENERAR CAMBIOS DE PRIVILEGIOS (HIGH) + # ============================================ + print("👤 Generando cambios de privilegios...") + + roles = ["AGENT", "CLIENT_USER", "AUDITOR", "SUPPORT_MANAGER", "ADMIN"] + + # Generar 5 cambios de rol (esto hará que sea HIGH severity) + for i in range(5): + time_offset = timedelta(hours=random.randint(0, 23), minutes=random.randint(0, 59)) + old_role = random.choice(roles) + new_role = random.choice([r for r in roles if r != old_role]) + + log = AuditLog( + id=uuid.uuid4(), + tenant_id=tenant.id, + user_id=user.id, + action="user.update", + resource_type="user", + resource_id=uuid.uuid4(), + ip_address=random.choice(suspicious_ips), + user_agent="Mozilla/5.0 (Test Browser)", + old_values={"role": old_role}, + new_values={"role": new_role}, + metadata={"changed_by": user.email}, + created_at=now - time_offset + ) + db.add(log) + logs_created += 1 + + print(f" ✓ Creados {5} cambios de privilegios (HIGH severity)") + + # ============================================ + # 4. GENERAR LOGS NORMALES (para dar contexto) + # ============================================ + print("📋 Generando logs de actividad normal...") + + normal_actions = [ + "ticket.create", + "ticket.update", + "comment.create", + "user.login", + "ticket.view", + ] + + for i in range(20): + time_offset = timedelta(hours=random.randint(0, 23), minutes=random.randint(0, 59)) + action = random.choice(normal_actions) + + log = AuditLog( + id=uuid.uuid4(), + tenant_id=tenant.id, + user_id=user.id, + action=action, + resource_type=action.split('.')[0], + resource_id=uuid.uuid4(), + ip_address=random.choice(suspicious_ips), + user_agent="Mozilla/5.0 (Test Browser)", + metadata={"action": "normal_activity"}, + created_at=now - time_offset + ) + db.add(log) + logs_created += 1 + + print(f" ✓ Creados {20} logs de actividad normal") + + # Guardar todo + await db.commit() + + print() + print("=" * 60) + print(f"✅ GENERACIÓN COMPLETADA") + print(f" Total de logs creados: {logs_created}") + print() + print("📊 Amenazas esperadas en el análisis:") + print(" 🔴 1 amenaza CRÍTICA: 55 eliminaciones masivas") + print(" 🟠 1 amenaza HIGH: 25 intentos fallidos de login") + print(" 🟠 1 amenaza HIGH: 5 cambios de privilegios") + print() + print("🌐 Accede a la página de seguridad para ver el análisis") + print("=" * 60) + + +async def cleanup_test_data(): + """Elimina los logs de auditoría de prueba.""" + async with AsyncSessionLocal() as db: + tenant_result = await db.execute(select(Tenant).limit(1)) + tenant = tenant_result.scalar_one_or_none() + + if not tenant: + print("❌ No se encontró ningún tenant.") + return + + # Eliminar logs de las últimas 24 horas + now = datetime.now(timezone.utc) + cutoff = now - timedelta(hours=24) + + result = await db.execute( + select(AuditLog).where( + AuditLog.tenant_id == tenant.id, + AuditLog.created_at >= cutoff + ) + ) + logs = result.scalars().all() + + if not logs: + print("ℹ️ No hay logs de prueba para eliminar.") + return + + for log in logs: + await db.delete(log) + + await db.commit() + + print(f"✅ Eliminados {len(logs)} logs de prueba de las últimas 24 horas") + + +if __name__ == "__main__": + import sys + + if len(sys.argv) > 1 and sys.argv[1] == "cleanup": + print("🧹 Limpiando datos de prueba...") + asyncio.run(cleanup_test_data()) + else: + print("🚀 Generando datos de prueba para análisis de seguridad...") + print() + asyncio.run(generate_test_data()) + print() + print("💡 Para limpiar estos datos de prueba, ejecuta:") + print(" python scripts/generate_security_test_data.py cleanup") diff --git a/backend/scripts/generate_sla_test_data.py b/backend/scripts/generate_sla_test_data.py new file mode 100644 index 0000000..c67b611 --- /dev/null +++ b/backend/scripts/generate_sla_test_data.py @@ -0,0 +1,399 @@ +""" +Script para generar datos de prueba de SLA Management. +Crea tickets con diferentes estados de SLA para probar el dashboard. +""" +import asyncio +import sys +from pathlib import Path +from datetime import datetime, timedelta, timezone +import uuid +import random + +# Agregar el directorio raíz al path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.core.database import AsyncSessionLocal +from app.models.ticket import Ticket, TicketStatus, TicketPriority +from app.models.category import Category +from app.models.user import User +from app.models.tenant import Tenant +from app.models.system import System + + +async def generate_sla_test_data(): + """Genera tickets de prueba con diferentes estados de SLA.""" + async with AsyncSessionLocal() as db: + # Obtener tenant y usuarios + tenant_result = await db.execute(select(Tenant).limit(1)) + tenant = tenant_result.scalar_one_or_none() + + if not tenant: + print("❌ No se encontró ningún tenant. Ejecuta las migraciones primero.") + return + + # Obtener usuarios + users_result = await db.execute( + select(User).where(User.tenant_id == tenant.id).limit(5) + ) + users = list(users_result.scalars().all()) + + if not users: + print("❌ No se encontraron usuarios. Crea usuarios primero.") + return + + creator = users[0] + agents = users if len(users) > 1 else [creator] + + # Obtener o crear categorías + categories_result = await db.execute( + select(Category).where(Category.tenant_id == tenant.id) + ) + categories = list(categories_result.scalars().all()) + + if not categories: + print("📁 Creando categorías de prueba...") + category_data = [ + {"name": "Soporte Técnico", "sla_response_hours": 2, "sla_resolution_hours": 24, "color": "#3B82F6"}, + {"name": "Facturación", "sla_response_hours": 4, "sla_resolution_hours": 48, "color": "#10B981"}, + {"name": "Incidente Crítico", "sla_response_hours": 1, "sla_resolution_hours": 8, "color": "#EF4444"}, + {"name": "Consulta General", "sla_response_hours": 8, "sla_resolution_hours": 72, "color": "#6B7280"}, + ] + + for cat_data in category_data: + category = Category( + id=uuid.uuid4(), + tenant_id=tenant.id, + name=cat_data["name"], + description=f"Categoría de {cat_data['name']}", + color=cat_data["color"], + sla_response_hours=cat_data["sla_response_hours"], + sla_resolution_hours=cat_data["sla_resolution_hours"], + is_active=True + ) + db.add(category) + categories.append(category) + + await db.commit() + print(f" ✓ Creadas {len(categories)} categorías") + + # Obtener o crear sistemas afectados + systems_result = await db.execute( + select(System).where(System.tenant_id == tenant.id) + ) + systems = list(systems_result.scalars().all()) + + if not systems: + print("🖥️ Creando sistemas de prueba...") + system_names = ["Portal Web", "API REST", "Base de Datos", "Sistema de Pagos"] + for sys_name in system_names: + system = System( + id=uuid.uuid4(), + tenant_id=tenant.id, + name=sys_name, + description=f"Sistema {sys_name}", + is_active=True + ) + db.add(system) + systems.append(system) + + await db.commit() + print(f" ✓ Creados {len(systems)} sistemas") + + print(f"✅ Usando tenant: {tenant.name}") + print(f"✅ Usuarios disponibles: {len(users)}") + print(f"✅ Categorías disponibles: {len(categories)}") + print() + + now = datetime.now(timezone.utc) + tickets_created = 0 + + # Función auxiliar para crear ticket + def create_ticket( + subject: str, + description: str, + priority: TicketPriority, + status: TicketStatus, + category: Category, + created_hours_ago: int, + first_response_hours_after: int = None, + resolved_hours_after: int = None, + assigned: bool = True + ): + nonlocal tickets_created + + ticket_id = uuid.uuid4() + created_at = now - timedelta(hours=created_hours_ago) + + # Calcular SLA deadlines basados en la categoría (sin timezone para la BD) + sla_response_due = (created_at + timedelta(hours=category.sla_response_hours)).replace(tzinfo=None) + sla_resolution_due = (created_at + timedelta(hours=category.sla_resolution_hours)).replace(tzinfo=None) + + # Primera respuesta (si aplica) + first_response_at = None + if first_response_hours_after is not None: + first_response_at = (created_at + timedelta(hours=first_response_hours_after)).replace(tzinfo=None) + + # Resolución (si aplica) + resolved_at = None + if resolved_hours_after is not None: + resolved_at = (created_at + timedelta(hours=resolved_hours_after)).replace(tzinfo=None) + + ticket = Ticket( + id=ticket_id, + tenant_id=tenant.id, + ticket_number=f"TKT-{1000 + tickets_created}", + subject=subject, + description=description, + status=status, + priority=priority, + created_by=creator.id, + assigned_to=random.choice(agents).id if assigned else None, + category_id=category.id, + affected_system_id=random.choice(systems).id if systems else None, + sla_response_due=sla_response_due, + sla_resolution_due=sla_resolution_due, + first_response_at=first_response_at, + resolved_at=resolved_at, + created_at=created_at, + updated_at=resolved_at or first_response_at or created_at + ) + + db.add(ticket) + tickets_created += 1 + return ticket + + # ============================================ + # 1. TICKETS CUMPLIENDO SLA RESPONSE (Verde) + # ============================================ + print("✅ Generando tickets CUMPLIENDO Response SLA...") + + for i in range(15): + category = random.choice(categories) + priority = random.choice([TicketPriority.LOW, TicketPriority.MEDIUM, TicketPriority.HIGH]) + + # Creado hace X horas, respondido ANTES del deadline + created_hours_ago = random.randint(24, 120) + response_time = random.uniform(0.5, category.sla_response_hours * 0.7) # 70% del SLA + + status = random.choice([TicketStatus.IN_PROGRESS, TicketStatus.WAITING_CUSTOMER]) + + create_ticket( + subject=f"Ticket con respuesta a tiempo #{i+1}", + description=f"Este ticket fue respondido dentro del SLA de {category.name}", + priority=priority, + status=status, + category=category, + created_hours_ago=created_hours_ago, + first_response_hours_after=response_time, + assigned=True + ) + + print(f" ✓ Creados 15 tickets cumpliendo Response SLA") + + # ============================================ + # 2. TICKETS VIOLANDO SLA RESPONSE (Rojo) + # ============================================ + print("🔴 Generando tickets VIOLANDO Response SLA...") + + for i in range(8): + category = random.choice(categories) + priority = random.choice([TicketPriority.HIGH, TicketPriority.URGENT]) + + # Creado hace más tiempo que el SLA, SIN respuesta + created_hours_ago = category.sla_response_hours + random.randint(1, 10) + + create_ticket( + subject=f"Ticket SIN respuesta - VIOLACIÓN #{i+1}", + description=f"Este ticket lleva {created_hours_ago}h sin respuesta (SLA: {category.sla_response_hours}h)", + priority=priority, + status=random.choice([TicketStatus.NEW, TicketStatus.TRIAGE]), + category=category, + created_hours_ago=created_hours_ago, + first_response_hours_after=None, # Sin respuesta! + assigned=random.choice([True, False]) + ) + + print(f" ✓ Creados 8 tickets VIOLANDO Response SLA") + + # ============================================ + # 3. TICKETS EN RIESGO Response (Amarillo) + # ============================================ + print("⚠️ Generando tickets EN RIESGO Response SLA...") + + for i in range(10): + category = random.choice(categories) + priority = random.choice([TicketPriority.MEDIUM, TicketPriority.HIGH, TicketPriority.URGENT]) + + # Creado hace tiempo, cerca del deadline (80-95% consumido) + sla_hours = category.sla_response_hours + time_consumed = random.uniform(0.8, 0.95) * sla_hours + created_hours_ago = time_consumed + + create_ticket( + subject=f"Ticket cerca de vencer respuesta #{i+1}", + description=f"Este ticket está al {int(time_consumed/sla_hours*100)}% del SLA de respuesta", + priority=priority, + status=random.choice([TicketStatus.TRIAGE, TicketStatus.NEW]), + category=category, + created_hours_ago=created_hours_ago, + first_response_hours_after=None, # Aún sin respuesta + assigned=True + ) + + print(f" ✓ Creados 10 tickets EN RIESGO Response SLA") + + # ============================================ + # 4. TICKETS CUMPLIENDO SLA RESOLUTION + # ============================================ + print("✅ Generando tickets CUMPLIENDO Resolution SLA...") + + for i in range(20): + category = random.choice(categories) + priority = random.choice([TicketPriority.LOW, TicketPriority.MEDIUM, TicketPriority.HIGH]) + + # Creado, respondido y resuelto dentro del SLA + created_hours_ago = random.randint(72, 240) + response_time = random.uniform(1, category.sla_response_hours * 0.5) + resolution_time = random.uniform( + response_time + 1, + category.sla_resolution_hours * 0.8 + ) + + create_ticket( + subject=f"Ticket resuelto a tiempo #{i+1}", + description=f"Este ticket fue resuelto dentro del SLA de {category.name}", + priority=priority, + status=random.choice([TicketStatus.RESOLVED, TicketStatus.CLOSED]), + category=category, + created_hours_ago=created_hours_ago, + first_response_hours_after=response_time, + resolved_hours_after=resolution_time, + assigned=True + ) + + print(f" ✓ Creados 20 tickets cumpliendo Resolution SLA") + + # ============================================ + # 5. TICKETS VIOLANDO SLA RESOLUTION + # ============================================ + print("🔴 Generando tickets VIOLANDO Resolution SLA...") + + for i in range(6): + category = random.choice(categories) + priority = random.choice([TicketPriority.HIGH, TicketPriority.URGENT]) + + # Creado hace más del SLA de resolución, con respuesta pero sin resolver + created_hours_ago = category.sla_resolution_hours + random.randint(5, 48) + response_time = random.uniform(1, category.sla_response_hours * 0.5) + + create_ticket( + subject=f"Ticket sin resolver - VIOLACIÓN #{i+1}", + description=f"Ticket lleva {created_hours_ago}h sin resolver (SLA: {category.sla_resolution_hours}h)", + priority=priority, + status=random.choice([TicketStatus.IN_PROGRESS, TicketStatus.WAITING_CUSTOMER]), + category=category, + created_hours_ago=created_hours_ago, + first_response_hours_after=response_time, + resolved_hours_after=None, # Sin resolver! + assigned=True + ) + + print(f" ✓ Creados 6 tickets VIOLANDO Resolution SLA") + + # ============================================ + # 6. TICKETS EN RIESGO Resolution + # ============================================ + print("⚠️ Generando tickets EN RIESGO Resolution SLA...") + + for i in range(12): + category = random.choice(categories) + priority = random.choice([TicketPriority.MEDIUM, TicketPriority.HIGH]) + + # Con respuesta, cerca del deadline de resolución + sla_hours = category.sla_resolution_hours + time_consumed = random.uniform(0.75, 0.95) * sla_hours + created_hours_ago = time_consumed + response_time = random.uniform(0.5, category.sla_response_hours * 0.5) + + create_ticket( + subject=f"Ticket cerca de vencer resolución #{i+1}", + description=f"Este ticket está al {int(time_consumed/sla_hours*100)}% del SLA de resolución", + priority=priority, + status=TicketStatus.IN_PROGRESS, + category=category, + created_hours_ago=created_hours_ago, + first_response_hours_after=response_time, + resolved_hours_after=None, + assigned=True + ) + + print(f" ✓ Creados 12 tickets EN RIESGO Resolution SLA") + + # Guardar todos los tickets + await db.commit() + + print() + print("=" * 70) + print("✅ GENERACIÓN DE DATOS SLA COMPLETADA") + print(f" Total de tickets creados: {tickets_created}") + print() + print("📊 Distribución esperada:") + print(" ✅ Response cumplidos: 15 tickets") + print(" 🔴 Response violados: 8 tickets") + print(" ⚠️ Response en riesgo: 10 tickets") + print(" ✅ Resolution cumplidos: 20 tickets") + print(" 🔴 Resolution violados: 6 tickets") + print(" ⚠️ Resolution en riesgo: 12 tickets") + print() + print("🌐 Ve los resultados en:") + print(" Dashboard SLA: http://localhost:3001/sla") + print("=" * 70) + + +async def cleanup_sla_test_data(): + """Elimina tickets de prueba.""" + async with AsyncSessionLocal() as db: + tenant_result = await db.execute(select(Tenant).limit(1)) + tenant = tenant_result.scalar_one_or_none() + + if not tenant: + print("❌ No se encontró ningún tenant.") + return + + # Eliminar tickets que empiezan con TKT- + result = await db.execute( + select(Ticket).where( + Ticket.tenant_id == tenant.id, + Ticket.ticket_number.like('TKT-%') + ) + ) + tickets = result.scalars().all() + + if not tickets: + print("ℹ️ No hay tickets de prueba para eliminar.") + return + + for ticket in tickets: + await db.delete(ticket) + + await db.commit() + + print(f"✅ Eliminados {len(tickets)} tickets de prueba") + + +if __name__ == "__main__": + import sys + + if len(sys.argv) > 1 and sys.argv[1] == "cleanup": + print("🧹 Limpiando datos de prueba de SLA...") + print() + asyncio.run(cleanup_sla_test_data()) + else: + print("🚀 Generando datos de prueba para SLA Management...") + print() + asyncio.run(generate_sla_test_data()) + print() + print("💡 Para limpiar estos datos de prueba, ejecuta:") + print(" python scripts/generate_sla_test_data.py cleanup") diff --git a/frontend-internal/src/routes/+layout.svelte b/frontend-internal/src/routes/+layout.svelte index 8738daa..d555adb 100644 --- a/frontend-internal/src/routes/+layout.svelte +++ b/frontend-internal/src/routes/+layout.svelte @@ -50,4 +50,4 @@ on:dismiss={() => toast.dismiss(toastMessage.id)} /> {/each} -

\ No newline at end of file + diff --git a/frontend-internal/src/routes/+page.svelte b/frontend-internal/src/routes/+page.svelte index 852e9b4..8075b59 100644 --- a/frontend-internal/src/routes/+page.svelte +++ b/frontend-internal/src/routes/+page.svelte @@ -16,28 +16,28 @@ description: 'Gestión de organizaciones y tenants', icon: 'users', href: '/tenants', - color: 'bg-blue-500' + color: 'bg-blue-600' }, { title: 'Usuarios', description: 'Administración de usuarios y roles', icon: 'user-plus', href: '/users', - color: 'bg-green-500' + color: 'bg-green-600' }, { title: 'Sistemas', description: 'Catálogo de sistemas soportados', icon: 'server', href: '/systems', - color: 'bg-purple-500' + color: 'bg-gray-700' }, { title: 'Categorías', description: 'Clasificación de tickets', icon: 'tag', href: '/categories', - color: 'bg-orange-500' + color: 'bg-orange-600' } ]; @@ -62,32 +62,20 @@ {#each cards as card}
-
-
-
- - - - +
+
+ {card.title} +
+
+
+ {card.description}
-
-
-
-
- {card.title} -
-
-
- {card.description} -
-
-
-
-
+ +
- + Ver detalles
diff --git a/frontend-internal/src/routes/audit/+page.svelte b/frontend-internal/src/routes/audit/+page.svelte index 96359bb..86440fb 100644 --- a/frontend-internal/src/routes/audit/+page.svelte +++ b/frontend-internal/src/routes/audit/+page.svelte @@ -83,7 +83,7 @@ label: 'Esta Semana', value: stats?.actions_this_week, icon: 'calendar', - color: 'indigo', + color: 'blue', desc: 'Últimos 7 días de actividad' }, { @@ -197,7 +197,7 @@ params.all_tenants = true; } stats = await api.get('/audit/stats', params); - } catch (e: any) { + } catch (e) { console.error('Error cargando estadísticas:', e); } } @@ -417,11 +417,12 @@ * Obtener color de badge según tipo de acción */ function getActionColor(action: string): string { - if (action.includes('delete')) return 'bg-red-600 text-white'; - if (action.includes('update')) return 'bg-blue-600 text-white'; - if (action.includes('login') || action.includes('logout')) return 'bg-indigo-600 text-white'; - if (action.includes('create')) return 'bg-green-600 text-white'; - return 'bg-gray-600 text-white'; + if (action.includes('delete')) return 'bg-gray-50 text-red-700 border border-red-200'; + if (action.includes('update')) return 'bg-gray-50 text-blue-700 border border-blue-200'; + if (action.includes('login') || action.includes('logout')) + return 'bg-gray-50 text-blue-700 border border-blue-200'; + if (action.includes('create')) return 'bg-gray-50 text-green-700 border border-green-200'; + return 'bg-gray-50 text-gray-700 border border-gray-200'; } /** @@ -430,15 +431,15 @@ function getSeverityColor(severity: string): string { switch (severity?.toLowerCase()) { case 'critical': - return 'bg-red-600 text-white'; + return 'bg-gray-50 text-red-700 border border-red-200'; case 'high': - return 'bg-orange-600 text-white'; + return 'bg-gray-50 text-orange-700 border border-orange-200'; case 'medium': - return 'bg-yellow-500 text-white'; + return 'bg-gray-50 text-yellow-800 border border-yellow-200'; case 'low': - return 'bg-blue-600 text-white'; + return 'bg-gray-50 text-blue-700 border border-blue-200'; default: - return 'bg-gray-600 text-white'; + return 'bg-gray-50 text-gray-700 border border-gray-200'; } } @@ -449,14 +450,14 @@ switch (status?.toLowerCase()) { case 'active': case 'open': - return 'bg-blue-600 text-white'; + return 'bg-gray-50 text-blue-700 border border-blue-200'; case 'resolved': case 'closed': - return 'bg-green-600 text-white'; + return 'bg-gray-50 text-green-700 border border-green-200'; case 'investigating': - return 'bg-yellow-500 text-white'; + return 'bg-gray-50 text-yellow-800 border border-yellow-200'; default: - return 'bg-gray-600 text-white'; + return 'bg-gray-50 text-gray-700 border border-gray-200'; } } @@ -572,64 +573,109 @@

- -
-

Período de Consulta

-
- {#each periodButtons as btn} - + {/each} +
+ + {#if periodFilter === 'custom'} +
+
+ - - - {/if} - {btn.label} - - {/each} + +
+
+ + +
+
+ {/if}
- {#if periodFilter === 'custom'} -
-
- + {#if securityAnalysis} + +
+
+
+

Análisis de Seguridad

+

Panel especializado de amenazas

+
+
+
- + {securityAnalysis.overall_risk_level} +
-
- - + +
+
+
+ {securityAnalysis.failed_login_attempts} +
+

Intentos fallidos

+
+
+
+ {securityAnalysis.suspicious_ips_count} +
+

IPs sospechosas

+
+
+
+ {securityAnalysis.total_threats_detected || 0} +
+

Amenazas

+
-
+ +
+ Ver análisis completo +
+
{/if}
@@ -663,11 +709,6 @@ - - - Multi-tenant activo {/if} @@ -683,69 +724,10 @@ {#each statsCards as card}
-
-
- {#if card.icon === 'clipboard'} - - {:else if card.icon === 'zap'} - - {:else if card.icon === 'calendar'} - - {:else if card.icon === 'alert'} - - {/if} -
+
filterCriticalActions()} title="Ver incidentes críticos" > Ver detalles - {/if}
@@ -797,35 +771,17 @@ class="w-full px-6 py-4 flex items-center justify-between text-left hover:bg-gray-50 transition-colors" >
-
- - - -
Filtros Avanzados {#if activeFiltersCount > 0} {activeFiltersCount} {/if}
- - - {#if showAdvancedFilters} @@ -839,7 +795,7 @@ bind:value={searchText} on:input={applyFilters} placeholder="Buscar en acciones..." - class="block w-full px-4 py-2.5 rounded-lg border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200 transition-all text-sm" + class="block w-full px-4 py-2.5 rounded-lg border border-gray-300 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200 transition-all text-sm" />
@@ -905,112 +861,6 @@ {/if}
- - {#if securityAnalysis} - - {/if} -

Incidentes de Seguridad

@@ -1018,19 +868,6 @@
- - - Eventos de seguridad detectados
@@ -1048,12 +885,12 @@ placeholder="Buscar incidentes..." bind:value={incidentSearchText} on:input={applyIncidentFilters} - class="block w-full px-4 py-2.5 rounded-lg border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200 transition-all text-sm" + class="block w-full px-4 py-2.5 rounded-lg border border-gray-300 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200 transition-all text-sm" /> @@ -1074,16 +911,8 @@
@@ -1093,29 +922,12 @@ {#if isLoadingIncidents}
Cargando incidentes...
{:else if incidents.length === 0}
-
- - - -

No hay incidentes

No se encontraron incidentes de seguridad para los filtros seleccionados. @@ -1131,33 +943,6 @@ >

-
-
- - - -
-

{incident.title}

@@ -1194,7 +979,7 @@ class="px-6 py-4 border-t border-gray-200 bg-gray-50 flex items-center justify-between" >

- Página {incidentsPage} de + Página {incidentsPage} de {incidentsTotalPages}
@@ -1202,33 +987,17 @@ type="button" on:click={() => goToIncidentsPage(incidentsPage - 1)} disabled={incidentsPage === 1} - class="px-4 py-2 text-sm font-medium bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-1" + class="px-4 py-2 text-sm font-medium bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" > - - - Anterior
@@ -1249,41 +1018,18 @@ >
-
- - - -
Historial completo de actividades - {totalLogs} registros
@@ -1301,19 +1047,6 @@ {:else if logs.length === 0}
- - -

No hay registros

{periodFilter === 'today' @@ -1369,7 +1102,7 @@ {#if log.user_email}

{(log.user_name || '?').charAt(0).toUpperCase()} @@ -1384,153 +1117,23 @@
{:else}
- - - Sistema
{/if} -
- {#if log.action === 'CREATE'} -
- - - -
- {:else if log.action === 'UPDATE'} -
- - - -
- {:else if log.action === 'DELETE'} -
- - - -
- {:else if log.action === 'LOGIN'} -
- - - -
- {:else if log.action === 'LOGIN_FAILED'} -
- - - -
- {:else} -
- - - -
- {/if} - - {formatActionText(log.action)} - -
+ + {formatActionText(log.action)} +
{log.resource_type}
{#if log.ip_address} -
- - - +
{log.ip_address}
{/if} @@ -1539,27 +1142,8 @@ @@ -1580,7 +1164,7 @@
{#if log.user_email}
{(log.user_name || '?').charAt(0).toUpperCase()} @@ -1590,24 +1174,7 @@
- - - + SYS
{/if} @@ -1632,20 +1199,7 @@ {log.resource_type}
{#if log.ip_address} -
- - - +
{log.ip_address}
{/if} @@ -1654,17 +1208,10 @@
@@ -1680,18 +1227,10 @@ disabled={currentPage === 1} class="relative inline-flex items-center gap-1 px-4 py-2 border border-gray-300 text-sm font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" > - - - Anterior - Página {currentPage} / {totalPages} + Página {currentPage} / {totalPages}
@@ -1720,7 +1251,7 @@ >{Math.min(currentPage * perPage, totalLogs)} de - {totalLogs} registros + {totalLogs} registros
diff --git a/frontend-internal/src/routes/audit/+page.svelte.tmp b/frontend-internal/src/routes/audit/+page.svelte.tmp new file mode 100644 index 0000000..3880c0e --- /dev/null +++ b/frontend-internal/src/routes/audit/+page.svelte.tmp @@ -0,0 +1,1350 @@ + + +
+ +
+
+

Auditoría del Sistema

+

+ Registro de actividades • + + {periodFilter === 'today' ? 'Hoy' : + periodFilter === 'yesterday' ? 'Ayer' : + periodFilter === 'last7days' ? 'Últimos 7 días' : + periodFilter === 'last30days' ? 'Últimos 30 días' : + 'Período personalizado'} + +

+
+
+ + +
+
+ + + + + +
+ + + {#if periodFilter === 'custom'} +
+
+ + +
+
+ + +
+
+ {/if} +
+ + + {#if canSeeAllTenants} +
+
+
+ +
+ {#if allTenants} + + + + + Multi-tenant activo + + {/if} +
+
+ {/if} + + + {#if stats} +
+
+
Total
+
{stats.total_actions.toLocaleString()}
+
+
+
Hoy
+
{stats.actions_today}
+
+
+
Esta Semana
+
{stats.actions_this_week}
+
+
+
+ + + +
Incidentes Criticos
+
+
+
+ {stats.critical_actions_today || 0} +
+ +
+
Incidentes críticos hoy
+
+
+ {/if} + + +
+ + + {#if showAdvancedFilters} +
+
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ + {#if activeFiltersCount > 0} +
+ +
+ {/if} +
+ {/if} +
+ + +
+
+
+

Incidentes de Seguridad

+ {totalIncidents} incidentes +
+
+ + +
+
+ + + + +
+
+ + +
+ {#if isLoadingIncidents} +
+
+ Cargando incidentes... +
+ {:else if incidents.length === 0} +
+ + + +

No hay incidentes

+

No se encontraron incidentes de seguridad para los filtros seleccionados.

+
+ {:else} +
+ {#each incidents as incident (incident.id)} +
viewIncidentDetail(incident)}> +
+
+
+ + + +
+
+

{incident.title}

+

{incident.description || 'Sin descripción'}

+
+
+
+ + {incident.severity?.toUpperCase()} + + + {incident.status?.toUpperCase()} + + + {formatSimpleDate(incident.created_at)} + +
+
+
+ {/each} +
+ + + {#if incidentsTotalPages > 1} +
+
+ Página {incidentsPage} de {incidentsTotalPages} +
+
+ + +
+
+ {/if} + {/if} +
+
+ + +
+
+
+

+ Registros de Auditoría +

+ {totalLogs} registros +
+
+ + {#if isLoading} +
+
+
+

Cargando registros...

+
+
+ {:else if logs.length === 0} +
+
+ + + +

No hay registros

+

+ {periodFilter === 'today' ? 'No hay actividad registrada hoy.' : 'No se encontraron registros para el período seleccionado.'} +

+
+
+ {:else} + +
+ + + + +
+ {#each logs as log (log.id)} +
+
+
+ + {#if log.user_email} +
+ + {(log.user_name || '?').charAt(0).toUpperCase()} + +
+ {:else} +
+ + + +
+ {/if} + + +
+
+ {formatDateShort(log.created_at)} + + {formatActionText(log.action)} + +
+
+ {log.user_name || 'Sistema'} + • {getRoleText(log.user_role)} +
+
+ {log.resource_type} + {#if log.ip_address} + • {log.ip_address} + {/if} +
+
+
+ + + +
+
+ {/each} +
+
+ + + {#if totalPages > 1} +
+ +
+ + + Página {currentPage} de {totalPages} + + +
+ + + +
+ {/if} + {/if} +
+
+ + +{#if showIncidentModal && selectedIncident} + showIncidentModal = false}> +
+ +
+

Información General

+
+
+
Título:
+
{selectedIncident.title}
+
+
+
Severidad:
+
+ + {selectedIncident.severity?.toUpperCase()} + +
+
+
+
Estado:
+
+ + {selectedIncident.status?.toUpperCase()} + +
+
+
+
Fecha:
+
{formatDate(selectedIncident.created_at)}
+
+ {#if selectedIncident.affected_user} +
+
Usuario Afectado:
+
{selectedIncident.affected_user}
+
+ {/if} + {#if selectedIncident.source_ip} +
+
IP Origen:
+
{selectedIncident.source_ip}
+
+ {/if} +
+
+ + + {#if selectedIncident.description} +
+

Descripción

+
+ {selectedIncident.description} +
+
+ {/if} + + + {#if selectedIncident.evidence && selectedIncident.evidence.length > 0} +
+

Evidencia

+
+
    + {#each selectedIncident.evidence as evidence} +
  • {evidence}
  • + {/each} +
+
+
+ {/if} + + + {#if selectedIncident.metadata && Object.keys(selectedIncident.metadata).length > 0} +
+

Información Adicional

+
{JSON.stringify(selectedIncident.metadata, null, 2)}
+
+ {/if} +
+ +
+ +
+
+{/if} + + +{#if showDetailModal && selectedLog}} + showDetailModal = false}> +
+ +
+

Información General

+
+
+
Fecha y Hora:
+
{formatDate(selectedLog.created_at)}
+
+
+
Usuario:
+
{selectedLog.user_name || 'Sistema'}
+
+
+
Email:
+
{selectedLog.user_email || 'N/A'}
+
+ {#if selectedLog.user_role} +
+
Rol:
+
{getRoleText(selectedLog.user_role)}
+
+ {/if} +
+
IP:
+
{selectedLog.ip_address || 'N/A'}
+
+
+
Correlation ID:
+
{selectedLog.correlation_id || 'N/A'}
+
+
+
+ + +
+

Acción

+
+ + {selectedLog.action} + +

{formatActionText(selectedLog.action)}

+
+
+ + +
+

Recurso Afectado

+
+
Tipo: {selectedLog.resource_type}
+ {#if selectedLog.resource_id} +
ID: {selectedLog.resource_id}
+ {/if} +
+
+ + + {#if selectedLog.user_agent} +
+

Navegador / Dispositivo

+
+ {selectedLog.user_agent} +
+
+ {/if} + + + {#if selectedLog.old_values && Object.keys(selectedLog.old_values).length > 0} +
+

Valores Anteriores

+
{JSON.stringify(selectedLog.old_values, null, 2)}
+
+ {/if} + + + {#if selectedLog.new_values && Object.keys(selectedLog.new_values).length > 0} +
+

Valores Nuevos

+
{JSON.stringify(selectedLog.new_values, null, 2)}
+
+ {/if} + + + {#if selectedLog.metadata && Object.keys(selectedLog.metadata).length > 0} +
+

Información Adicional

+
{JSON.stringify(selectedLog.metadata, null, 2)}
+
+ {/if} +
+ +
+ +
+
+{/if} \ No newline at end of file diff --git a/frontend-internal/src/routes/audit/security/+page.svelte b/frontend-internal/src/routes/audit/security/+page.svelte index a7210e4..25ed5bf 100644 --- a/frontend-internal/src/routes/audit/security/+page.svelte +++ b/frontend-internal/src/routes/audit/security/+page.svelte @@ -7,19 +7,61 @@ // Estado let isLoading = false; - let analysis = null; + let analysis: any = null; let analysisHours = 24; - let selectedThreat = null; + let selectedThreat: any = null; let showActionModal = false; let actionType = ''; let actionTarget = ''; let actionReason = ''; let actionDuration = 60; + // Filtros y búsqueda + let activeTab: 'all' | 'critical' | 'high' | 'medium' | 'low' | 'resolved' = 'all'; + let searchQuery = ''; + let filterType = ''; + let showFilters = false; + let selectedThreats = new Set(); + + // Estado de amenazas resueltas (simulado - idealmente vendría del backend) + let resolvedThreats = new Set(); + // Usuario actual $: currentUser = $auth.user; $: canExecuteActions = currentUser && (currentUser.role === 'ADMIN' || currentUser.role === 'SUPPORT_MANAGER'); + // Amenazas filtradas + $: filteredThreats = analysis?.threats?.filter((threat: any) => { + const matchesTab = + activeTab === 'all' ? !resolvedThreats.has(threat.id) : + activeTab === 'resolved' ? resolvedThreats.has(threat.id) : + (threat.severity === activeTab && !resolvedThreats.has(threat.id)); + + const matchesSearch = !searchQuery || + threat.description.toLowerCase().includes(searchQuery.toLowerCase()) || + threat.type.toLowerCase().includes(searchQuery.toLowerCase()) || + threat.affected_ips.some((ip: string) => ip.includes(searchQuery)) || + threat.affected_users.some((user: string) => user.toLowerCase().includes(searchQuery.toLowerCase())); + + const matchesType = !filterType || threat.type === filterType; + + return matchesTab && matchesSearch && matchesType; + }) || []; + + // Contadores por tab + $: tabCounts = { + all: analysis?.threats?.filter((t: any) => !resolvedThreats.has(t.id)).length || 0, + critical: analysis?.threats?.filter((t: any) => t.severity === 'critical' && !resolvedThreats.has(t.id)).length || 0, + high: analysis?.threats?.filter((t: any) => t.severity === 'high' && !resolvedThreats.has(t.id)).length || 0, + medium: analysis?.threats?.filter((t: any) => t.severity === 'medium' && !resolvedThreats.has(t.id)).length || 0, + low: analysis?.threats?.filter((t: any) => t.severity === 'low' && !resolvedThreats.has(t.id)).length || 0, + resolved: resolvedThreats.size + }; + + // Tipos únicos de amenazas + $: threatTypes = analysis?.threats ? + [...new Set(analysis.threats.map((t: any) => t.type))] : []; + /** * Cargar análisis de seguridad */ @@ -44,32 +86,77 @@ } /** - * Obtener color según nivel de riesgo + * Obtener color según nivel de riesgo (neutral) */ function getRiskColor(level: string) { const colors: any = { - safe: 'bg-green-100 text-green-800 border-green-300', - low: 'bg-blue-100 text-blue-800 border-blue-300', - medium: 'bg-yellow-100 text-yellow-800 border-yellow-300', - high: 'bg-orange-100 text-orange-800 border-orange-300', - critical: 'bg-red-100 text-red-800 border-red-300' + safe: 'bg-gray-50 text-green-700 border border-green-200', + low: 'bg-gray-50 text-blue-700 border border-blue-200', + medium: 'bg-gray-50 text-yellow-800 border border-yellow-200', + high: 'bg-gray-50 text-orange-700 border border-orange-200', + critical: 'bg-gray-50 text-red-700 border border-red-200' }; return colors[level] || colors.low; } /** - * Obtener color de severidad de amenaza + * Obtener color de severidad de amenaza (neutral) */ function getSeverityColor(severity: string) { const colors: any = { - low: 'bg-blue-600 text-white', - medium: 'bg-yellow-500 text-white', - high: 'bg-orange-600 text-white', - critical: 'bg-red-600 text-white' + low: 'bg-gray-50 text-blue-700 border border-blue-200', + medium: 'bg-gray-50 text-yellow-800 border border-yellow-200', + high: 'bg-gray-50 text-orange-700 border border-orange-200', + critical: 'bg-gray-50 text-red-700 border border-red-200' }; return colors[severity] || colors.low; } + /** + * Marcar amenaza como resuelta + */ + function toggleThreatResolved(threatId: string) { + if (resolvedThreats.has(threatId)) { + resolvedThreats.delete(threatId); + } else { + resolvedThreats.add(threatId); + } + resolvedThreats = resolvedThreats; // Trigger reactivity + toast.success(resolvedThreats.has(threatId) ? 'Amenaza marcada como resuelta' : 'Amenaza marcada como activa'); + } + + /** + * Seleccionar/deseleccionar amenaza + */ + function toggleThreatSelection(threatId: string) { + if (selectedThreats.has(threatId)) { + selectedThreats.delete(threatId); + } else { + selectedThreats.add(threatId); + } + selectedThreats = selectedThreats; + } + + /** + * Resolver amenazas en lote + */ + function resolveSelectedThreats() { + selectedThreats.forEach(id => resolvedThreats.add(id)); + resolvedThreats = resolvedThreats; + selectedThreats.clear(); + selectedThreats = selectedThreats; + toast.success(`${resolvedThreats.size} amenazas resueltas`); + } + + /** + * Limpiar filtros + */ + function clearFilters() { + searchQuery = ''; + filterType = ''; + activeTab = 'all'; + } + /** * Obtener icono de tipo de amenaza */ @@ -174,130 +261,140 @@
- +
+ +
-
-

- - - - Análisis de Seguridad -

-

- Detección de amenazas y análisis de vulnerabilidades -

+
+
+

Análisis de Seguridad

+

Detección de amenazas y gestión de incidentes

+
+
+
+
-
-
-
- - - - Período de Análisis +
+
+ Período de Análisis +
+ {#if analysis} + + Última actualización: {formatDate(analysis.generated_at)} + + {/if}
+
-
{#if isLoading} -
-
+
+
+
+

Analizando seguridad...

+
{:else if analysis} - -
-
-
-

Nivel de Riesgo General

-

Análisis de {analysis.analysis_period_hours} horas

+ +
+ +
+
+ Nivel de Riesgo
-
- +
+ {analysis.overall_risk_level.toUpperCase()} -

Generado: {formatDate(analysis.generated_at)}

+

{analysis.analysis_period_hours}h análisis

-
- -
-
+ +
-
-

Amenazas Detectadas

-

{analysis.total_threats_detected}

+
+

Amenazas

+

{analysis.total_threats_detected}

+

Detectadas

- - -
-
+ +
-
-

Intentos Fallidos

-

{analysis.failed_login_attempts}

+
+

Intentos Fallidos

+

{analysis.failed_login_attempts}

+

Logins rechazados

- - -
-
+ +
-
-

IPs Sospechosas

-

{analysis.suspicious_ips_count}

+
+

IPs Sospechosas

+

{analysis.suspicious_ips_count}

+

En seguimiento

- - -
-
+ +
-
-

Acciones Críticas

-

{analysis.critical_actions_count}

+
+

Acciones Críticas

+

{analysis.critical_actions_count}

+

Registradas

- - -
@@ -305,162 +402,335 @@ {#if analysis.recommended_actions && analysis.recommended_actions.length > 0}
-
- - - -
-

Acciones Recomendadas

-
    - {#each analysis.recommended_actions as action} -
  • - - - - {action} -
  • - {/each} -
-
+
+

Acciones Recomendadas

+
    + {#each analysis.recommended_actions as action} +
  • {action}
  • + {/each} +
{/if} + +
+ +
+
+ + + + + + +
+
+ + +
+
+ +
+ +
+ + + + + + {#if searchQuery || filterType} + + {/if} +
+ + + {#if selectedThreats.size > 0 && canExecuteActions} +
+ + {selectedThreats.size} amenaza{selectedThreats.size > 1 ? 's' : ''} seleccionada{selectedThreats.size > 1 ? 's' : ''} + +
+ + +
+
+ {/if} +
+
+ - {#if analysis.threats && analysis.threats.length > 0} -
-

Amenazas Detectadas

- - {#each analysis.threats as threat} -
- -
-
-
- - - -
-
-
-

{getThreatTypeText(threat.type)}

- - {threat.severity.toUpperCase()} - + {#if filteredThreats.length > 0} +
+ {#each filteredThreats as threat} +
+ +
+
+
+ + {#if canExecuteActions && !resolvedThreats.has(threat.id)} + toggleThreatSelection(threat.id)} + class="mt-1 h-4 w-4 text-blue-700 border-gray-300 rounded focus:ring-blue-500" + /> + {/if} + + + + +
+
+

{getThreatTypeText(threat.type)}

+ + {threat.severity.toUpperCase()} + + {#if resolvedThreats.has(threat.id)} + + RESUELTA + + {/if} +
+

{threat.description}

+ + +
+ {threat.occurrences} ocurrencias + {#if threat.affected_ips.length > 0} + {threat.affected_ips.length} IPs + {/if} + {#if threat.affected_users.length > 0} + {threat.affected_users.length} usuarios + {/if} + | + {formatDate(threat.last_seen)} +
+ + + {#if threat.affected_ips.length > 0 || threat.affected_users.length > 0} +
+ + Ver detalles afectados + +
+ {#if threat.affected_ips.length > 0} +
+ IPs: +
+ {#each threat.affected_ips as ip} + {ip} + {/each} +
+
+ {/if} + {#if threat.affected_users.length > 0} +
+ Usuarios: +
+ {#each threat.affected_users as user} + {user} + {/each} +
+
+ {/if} +
+
+ {/if} + + + {#if threat.recommendations && threat.recommendations.length > 0} +
+ + Ver recomendaciones + +
+
    + {#each threat.recommendations as rec} +
  • + {rec} +
  • + {/each} +
+
+
+ {/if}
-

{threat.description}

+ + + {#if canExecuteActions} +
+ {#if !resolvedThreats.has(threat.id)} + + {:else} + + {/if} + + {#if !resolvedThreats.has(threat.id)} +
+ + +
+ {/if} +
+ {/if}
- - -
-
- Ocurrencias: - {threat.occurrences} -
-
- Primera detección: - {formatDate(threat.first_seen)} -
-
- Última detección: - {formatDate(threat.last_seen)} -
-
- - - {#if threat.affected_ips.length > 0 || threat.affected_users.length > 0} -
- {#if threat.affected_ips.length > 0} -
- IPs involucradas: -
- {#each threat.affected_ips as ip} - {ip} - {/each} -
-
- {/if} - {#if threat.affected_users.length > 0} -
- Usuarios afectados: -
- {#each threat.affected_users as user} - {user} - {/each} -
-
- {/if} -
- {/if} - - - {#if threat.recommendations && threat.recommendations.length > 0} -
-

Recomendaciones:

-
    - {#each threat.recommendations as rec} -
  • - - - - {rec} -
  • - {/each} -
-
- {/if} - - - {#if canExecuteActions} -
- {#if threat.affected_ips.length > 0} - - {/if} - {#if threat.affected_users.length > 0} - - {/if} - -
- {/if}
{/each}
{:else} - -
- - - -

Sistema Seguro

-

No se detectaron amenazas en el período analizado

+ +
+

+ {activeTab === 'resolved' ? 'No hay amenazas resueltas' : searchQuery || filterType ? 'No se encontraron resultados' : 'Sistema Seguro'} +

+

+ {activeTab === 'resolved' ? 'No has resuelto ninguna amenaza aún.' : searchQuery || filterType ? 'Intenta ajustar los filtros de búsqueda.' : 'No se detectaron amenazas en este período.'} +

+ {#if searchQuery || filterType} + + {/if}
{/if} + {:else} + +
+

Sin Datos de Análisis

+

Carga el análisis de seguridad para ver amenazas detectadas.

+
{/if}
@@ -522,7 +792,7 @@ diff --git a/frontend-internal/src/routes/categories/+page.svelte b/frontend-internal/src/routes/categories/+page.svelte index c4460fb..0a23d1f 100644 --- a/frontend-internal/src/routes/categories/+page.svelte +++ b/frontend-internal/src/routes/categories/+page.svelte @@ -102,7 +102,7 @@ @@ -145,27 +145,27 @@ {category.description || '-'} - - ⏱️ {category.sla_response_hours || 24}h + + {category.sla_response_hours || 24}h - - ✅ {category.sla_resolution_hours || 72}h + + {category.sla_resolution_hours || 72}h - + {getTenantName(category.tenant_id)} - + {category.is_active ? 'Activo' : 'Inactivo'} - + {/each} @@ -182,18 +182,18 @@
- +
- +
- +
@@ -212,7 +212,7 @@ min="1" max="168" required - class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2" + class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2" >

Tiempo máximo para primera respuesta

@@ -228,7 +228,7 @@ min="1" max="720" required - class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2" + class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2" >

Tiempo máximo para resolver el ticket

@@ -247,7 +247,7 @@
- {#each tenants as tenant} @@ -256,15 +256,15 @@
- +
- -
diff --git a/frontend-internal/src/routes/login/+page.svelte b/frontend-internal/src/routes/login/+page.svelte index 185e047..b3a57b5 100644 --- a/frontend-internal/src/routes/login/+page.svelte +++ b/frontend-internal/src/routes/login/+page.svelte @@ -109,7 +109,7 @@
-

Identifíquese

+

Iniciar Sesión

Acceso al sistema central

@@ -208,4 +208,4 @@
-
\ No newline at end of file +
diff --git a/frontend-internal/src/routes/profile/+page.svelte b/frontend-internal/src/routes/profile/+page.svelte index 2150109..cbcb576 100644 --- a/frontend-internal/src/routes/profile/+page.svelte +++ b/frontend-internal/src/routes/profile/+page.svelte @@ -179,7 +179,7 @@ {$auth.user?.first_name} {$auth.user?.last_name}

{$auth.user?.email}

- + {roleLabel($auth.user?.role)}
@@ -197,7 +197,7 @@
Estado
- + {$auth.user?.is_active ? 'Activo' : 'Inactivo'}
@@ -219,10 +219,8 @@
{#if $auth.user?.is_two_factor_enabled} -
- - - +
+ 2FA

2FA habilitado

@@ -230,9 +228,7 @@
{:else}
- - - + 2FA

2FA no habilitado

@@ -306,7 +302,7 @@ {#if showBackupCodes && backupCodes.length > 0}
-

✅ 2FA activado — Guarda tus códigos de respaldo

+

2FA activado — Guarda tus códigos de respaldo

Estos códigos son de un solo uso. Guárdalos en un lugar seguro para acceder sin tu dispositivo.

diff --git a/frontend-internal/src/routes/sla/+page.svelte b/frontend-internal/src/routes/sla/+page.svelte index fc11375..8c9110b 100644 --- a/frontend-internal/src/routes/sla/+page.svelte +++ b/frontend-internal/src/routes/sla/+page.svelte @@ -55,7 +55,7 @@ @@ -91,115 +180,209 @@
- -
-
-
- - - + +
+
+

Filtros

+ {#if activeFiltersCount > 0} + + {/if} +
+ +
+ +
+ +
-
-

- Mostrando tickets que han consumido {threshold}% o más de su tiempo SLA. - Estos tickets requieren atención prioritaria para evitar violaciones. -

+ + +
+ +
+ + +
+ + +
+ + +
+ + +
+
+ + +
+

+ Mostrando {totalTickets} + {totalTickets === 1 ? 'ticket' : 'tickets'} + {#if activeFiltersCount > 0} + de {atRiskTickets.length} totales + {/if} +

+ + {#if !isLoading && filteredTickets.length > 0} +
+

Resumen por Nivel de Riesgo

+
+
+

Total en Riesgo

+

{totalTickets}

+
+
+

Crítico (≥95%)

+

+ {filteredTickets.filter(t => t.risk_percentage >= 95).length} +

+
+
+

Alto (90-95%)

+

+ {filteredTickets.filter(t => t.risk_percentage >= 90 && t.risk_percentage < 95).length} +

+
+
+

Medio (80-90%)

+

+ {filteredTickets.filter(t => t.risk_percentage >= 80 && t.risk_percentage < 90).length} +

+
+
+
+ {/if} + + +
+

+ {totalTickets} {totalTickets === 1 ? 'ticket' : 'tickets'} consumiendo {threshold}% o más del tiempo SLA. +

+
+ -
+
{#if isLoading} -
-
-

Cargando tickets en riesgo...

+
+
+

Cargando tickets en riesgo...

{:else if atRiskTickets.length === 0}
- - - -

¡Todo bajo control!

+

Todo bajo control

No hay tickets en riesgo de violar SLA

+ {:else if filteredTickets.length === 0} +
+

Sin resultados

+

No se encontraron tickets con los filtros aplicados

+ +
{:else} - {#each atRiskTickets as ticket} -
-
-
-
-
+ {#each paginatedTickets as ticket} +
+
+
+
+
{ticket.ticket.ticket_number} - + {ticket.ticket.priority} - + {getSLATypeLabel(ticket.sla_type)}
-

{ticket.ticket.subject}

+

{ticket.ticket.subject}

- {#if ticket.category} -
- 📂 {ticket.category.name} - - (SLA: {ticket.sla_type === 'response' ? ticket.category.sla_response_hours : ticket.category.sla_resolution_hours}h) - -
- {/if} - - {#if ticket.assigned_to} -
- 👤 Asignado a: {ticket.assigned_to.first_name} {ticket.assigned_to.last_name} -
- {/if} +
+ {#if ticket.category} + {ticket.category.name} + {/if} + {#if ticket.assigned_to} + {ticket.assigned_to.first_name} {ticket.assigned_to.last_name} + {/if} +
-
-
- {getRiskLabel(ticket.risk_percentage)} +
+
+ {ticket.risk_percentage.toFixed(0)}%
-
- - Progreso: {ticket.risk_percentage.toFixed(1)}% - -
-
- Quedan: {formatHours(ticket.time_remaining_hours)} +
+ {formatHours(ticket.time_remaining_hours)}
- -
-
-
-
-
-
- 0% - {threshold}% (umbral) - 100% -
-
+
+
+
+
- - @@ -207,34 +390,61 @@ {/if}
- - {#if !isLoading && atRiskTickets.length > 0} -
-

Resumen

-
+ + {#if !isLoading && totalPages > 1} +
+
+ + +
+
{/if} + +
diff --git a/frontend-internal/src/routes/sla/violations/+page.svelte b/frontend-internal/src/routes/sla/violations/+page.svelte index c2d4686..f101485 100644 --- a/frontend-internal/src/routes/sla/violations/+page.svelte +++ b/frontend-internal/src/routes/sla/violations/+page.svelte @@ -7,7 +7,7 @@ let violations: any[] = []; let total = 0; let page = 1; - let perPage = 20; + let perPage = 10; let totalPages = 0; // Filtros @@ -74,7 +74,7 @@ } function getSLATypeColor(type: string): string { - return type === 'response' ? 'bg-yellow-100 text-yellow-800' : 'bg-red-100 text-red-800'; + return type === 'response' ? 'bg-orange-100 text-orange-800' : 'bg-red-100 text-red-800'; } function handleFilterChange() { @@ -130,7 +130,7 @@ id="slaType" bind:value={slaTypeFilter} on:change={handleFilterChange} - class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" + class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" > @@ -144,7 +144,7 @@ id="category" bind:value={categoryFilter} on:change={handleFilterChange} - class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" + class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" > {#each categories as cat} @@ -159,7 +159,7 @@ id="priority" bind:value={priorityFilter} on:change={handleFilterChange} - class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" + class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" > @@ -186,22 +186,14 @@
-
-
-
- - - -
-
-

- {total} violaciones activas encontradas +

+

+ {total} {total === 1 ? 'violación activa' : 'violaciones activas'} encontradas {#if total > 0} - Requieren atención inmediata {/if}

-
@@ -238,18 +230,16 @@ {#if isLoading} - -
-

Cargando violaciones...

+ +
+

Cargando violaciones...

{:else if violations.length === 0} - - - - -

Excelente! No hay violaciones de SLA activas

+ +

¡Excelente trabajo!

+

No hay violaciones de SLA activas

{:else} @@ -259,7 +249,7 @@
{violation.ticket.ticket_number} @@ -290,11 +280,14 @@ - - {formatHours(violation.hours_overdue)} - -
- vencido +
+ + {formatHours(violation.hours_overdue)} + +
+ vencido +
+
@@ -314,7 +307,7 @@ Ver ticket → diff --git a/frontend-internal/src/routes/systems/+page.svelte b/frontend-internal/src/routes/systems/+page.svelte index 6354d64..86a107f 100644 --- a/frontend-internal/src/routes/systems/+page.svelte +++ b/frontend-internal/src/routes/systems/+page.svelte @@ -67,7 +67,7 @@ @@ -100,12 +100,12 @@ {system.name} {system.description || '-'} - + {system.is_active ? 'Activo' : 'Inactivo'} - + {/each} @@ -122,24 +122,24 @@
- +
- +
- +
- -
diff --git a/frontend-internal/src/routes/tenants/+page.svelte b/frontend-internal/src/routes/tenants/+page.svelte index 9a457ba..d321cd5 100644 --- a/frontend-internal/src/routes/tenants/+page.svelte +++ b/frontend-internal/src/routes/tenants/+page.svelte @@ -101,7 +101,7 @@ @@ -143,7 +143,7 @@ - + {tenant.status === 'active' ? 'Activo' : tenant.status === 'suspended' ? 'Suspendido' : 'Inactivo'}
- + {/each} @@ -176,28 +176,28 @@
- +
- +

Usado en URLs y subdominios.

- +
- +
- +
@@ -208,7 +208,7 @@
- - {#if editingTenant} diff --git a/frontend-internal/src/routes/tickets/+page.svelte b/frontend-internal/src/routes/tickets/+page.svelte index 14783f8..6155a12 100644 --- a/frontend-internal/src/routes/tickets/+page.svelte +++ b/frontend-internal/src/routes/tickets/+page.svelte @@ -312,7 +312,7 @@