Compare commits

..

6 Commits

Author SHA1 Message Date
cd3d7e816f Recuperar implementado 2026-02-27 10:08:30 -07:00
63925fe305 Login resuelto 2026-02-27 09:16:08 -07:00
c146a6c3c3 funcion dashboard y reporte de endpoints v1.16.0 2026-02-26 12:48:28 -07:00
ba779bde55 docs: guardar README original como README.legacy.md 2026-02-26 09:13:29 -07:00
ba94152074 docs: README completo para Windows/Linux/macOS + fix conflicto de puertos
- README.md reescrito con instrucciones detalladas para principiantes y expertos
  * Tabla de contenidos con 14 secciones
  * Inicio rápido con Docker (Windows, Linux, macOS)
  * Configuración de variables de entorno con explicaciones
  * Sección de desarrollo local sin Docker
  * Comandos útiles (Docker, Alembic, calidad de código, testing)
  * Solución de problemas extensa (puertos, módulos, tenant, migraciones, Node.js)
  * Historial de versiones

- Fix: conflicto de puertos cuando ambos frontends corren en local
  * frontend-internal/vite.config.js: usa PORT=3001 por defecto (3000 en Docker)
  * frontend-internal/package.json: dev script sin puerto hardcodeado
  * docker-compose.yml: frontend-internal recibe PORT=3000 como variable de env
2026-02-26 09:02:04 -07:00
bd21207aae v1.15.1 - modulo de reportes implementado
- Nuevo módulo de reportes: backend/app/api/v1/endpoints/reports.py
- Schemas de reportes: backend/app/api/schemas/reports.py
- Frontend: frontend-internal/src/routes/reports/
- Mejoras al módulo de auditoría (audit.py, audit_helpers.py)
- Modelo de auditoría actualizado
- Sidebar actualizado con enlace a reportes
2026-02-26 08:51:46 -07:00
24 changed files with 5384 additions and 1499 deletions

178
README.legacy.md Normal file
View File

@@ -0,0 +1,178 @@
# ServiceManagerWeb - Mesa de Ayuda B2B
Sistema multi-tenant de Mesa de Ayuda/Soporte Técnico empresarial para Aduanasoft.
## Arquitectura
- **Frontend**: SvelteKit + TypeScript (portal clientes + panel interno)
- **Backend**: Python FastAPI + Pydantic v2
- **Workers**: Celery + Redis (notificaciones, SLAs, jobs)
- **BD**: PostgreSQL + Alembic migrations
- **Auth**: JWT + Refresh tokens + 2FA opcional (TOTP)
- **Infra**: Docker Compose local, preparado para producción
## Estructura del Monorepo
```
ServiceManagerWeb/
├── backend/ # FastAPI app
├── frontend-client/ # SvelteKit app para clientes
├── frontend-internal/ # SvelteKit app para staff interno
├── workers/ # Celery tasks
├── db/ # Migrations y esquemas
├── docker/ # Dockerfiles específicos
├── docs/ # Documentación adicional
├── scripts/ # Scripts de desarrollo/despliegue
├── docker-compose.yml # Orquestación completa
└── .env.example # Variables de entorno
```
## Stack Tecnológico
### Backend (Python)
- FastAPI (async)
- Pydantic v2
- SQLAlchemy 2.0 (async)
- Alembic (migrations)
- Argon2 (hashing passwords)
- PyJWT
- Celery + Redis
### Frontend (JavaScript/TypeScript)
- SvelteKit
- TypeScript
- TailwindCSS
- shadcn/ui o similar
- Zod (validación)
### Infraestructura
- PostgreSQL 15+
- Redis 7+
- Docker & Docker Compose
- Nginx (reverse proxy)
## Dominios del Sistema
1. **Auth**: Usuarios, roles, permisos, 2FA
2. **Tenants**: Multi-tenancy, organizaciones
3. **Tickets**: Gestión de tickets, estados, SLAs
4. **Notifications**: Email, plantillas, logs
5. **Audit**: Bitácora de acciones
## Roles de Usuario
### Internos (Staff)
- `ADMIN`: Control total del sistema
- `SUPPORT_MANAGER`: Gestión de equipos y SLAs
- `AGENT`: Atención de tickets
- `AUDITOR`: Solo lectura para auditoría
### Clientes
- `CLIENT_ADMIN`: Gestión de organización cliente
- `CLIENT_USER`: Creación y seguimiento de tickets
## Quick Start
```bash
# Clonar y configurar
git clone <repo>
cd ServiceManagerWeb
cp .env.example .env
# Levantar servicios
docker-compose up -d
# Verificar estado
docker-compose ps
```
## URLs por Defecto
- Frontend Clientes: http://localhost:3000
- Frontend Interno: http://localhost:3001
- API Backend: http://localhost:8000
- API Docs: http://localhost:8000/docs
- Adminer (DB): http://localhost:8080
## Scripts de Desarrollo
```bash
# Backend
cd backend
python -m uvicorn app.main:app --reload --port 8000
# Frontend Cliente
cd frontend-client
npm run dev -- --port 3000
# Frontend Interno
cd frontend-internal
npm run dev -- --port 3001
# Workers
cd workers
celery -A app.worker worker --loglevel=info
celery -A app.worker beat --loglevel=info
```
## Testing
```bash
# Backend tests
cd backend
pytest
# Frontend tests
cd frontend-client
npm test
cd ../frontend-internal
npm test
```
## Troubleshooting
### Error 500 en Login / Proxy Error
**Síntoma**: Error 500 al intentar hacer login, o error de proxy de Vite "connect ECONNREFUSED".
**Causa**: Configuración incorrecta de la comunicación entre servicios de Docker.
**Solución**:
1. En desarrollo con Docker, los servicios usan nombres de servicio (no `localhost`)
2. Verificar `vite.config.js`: el proxy debe apuntar a `http://backend:8000`
3. Verificar `docker-compose.yml`: `PUBLIC_API_URL` debe ser `http://backend:8000`
4. Después de cambios, reiniciar contenedor: `docker-compose restart frontend-internal`
**Nota**: Para desarrollo local sin Docker, cambiar el proxy a `http://localhost:8000`.
### Tenant Slug Incorrecto
**Síntoma**: Error de autenticación incluso con credenciales correctas.
**Causa**: El `tenant_slug` en el login no coincide con los tenants en la BD.
**Solución**:
1. Verificar tenants existentes: `docker exec servicemanager-backend python check_tenants.py`
2. Actualizar el tenant_slug en el código de login
3. Tenants por defecto: `aduanasoft-demo`, `test-tenant`
### Credenciales de Prueba
```
Email: admin@aduanasoft.com
Password: admin123
Tenant: aduanasoft-demo
Role: ADMIN
```
## Contribución
1. Fork del proyecto
2. Crear feature branch (`git checkout -b feature/nueva-funcionalidad`)
3. Commit cambios (`git commit -am 'Agregar nueva funcionalidad'`)
4. Push a branch (`git push origin feature/nueva-funcionalidad`)
5. Crear Pull Request
## Licencia
Propietario - Aduanasoft © 2026

837
README.md
View File

@@ -1,178 +1,755 @@
# ServiceManagerWeb - Mesa de Ayuda B2B
# ServiceManagerWeb Mesa de Ayuda B2B
Sistema multi-tenant de Mesa de Ayuda/Soporte Técnico empresarial para Aduanasoft.
> **Versión actual:** v1.15.1 — Módulo de reportes implementado
>
> Sistema multi-tenant de Mesa de Ayuda / Soporte Técnico empresarial desarrollado para Aduanasoft.
> Arquitectura Modular Monolith con Clean Architecture, preparado para escalar a microservicios.
## Arquitectura
---
- **Frontend**: SvelteKit + TypeScript (portal clientes + panel interno)
- **Backend**: Python FastAPI + Pydantic v2
- **Workers**: Celery + Redis (notificaciones, SLAs, jobs)
- **BD**: PostgreSQL + Alembic migrations
- **Auth**: JWT + Refresh tokens + 2FA opcional (TOTP)
- **Infra**: Docker Compose local, preparado para producción
## Tabla de Contenidos
## Estructura del Monorepo
1. [Requisitos previos](#requisitos-previos)
2. [Inicio rápido con Docker (recomendado)](#inicio-rápido-con-docker-recomendado)
3. [Configuración de variables de entorno](#configuración-de-variables-de-entorno)
4. [Cargar datos de prueba](#cargar-datos-de-prueba)
5. [URLs y puertos por defecto](#urls-y-puertos-por-defecto)
6. [Credenciales de prueba](#credenciales-de-prueba)
7. [Desarrollo local sin Docker](#desarrollo-local-sin-docker)
8. [Arquitectura del proyecto](#arquitectura-del-proyecto)
9. [Roles y permisos](#roles-y-permisos)
10. [Comandos útiles](#comandos-útiles)
11. [Pruebas (testing)](#pruebas-testing)
12. [Solución de problemas](#solución-de-problemas)
13. [Contribución](#contribución)
14. [Historial de versiones](#historial-de-versiones)
---
## Requisitos previos
Antes de clonar el proyecto, asegúrate de tener instalado:
| Herramienta | Versión mínima | Descarga |
|-------------|---------------|---------|
| **Git** | 2.x | https://git-scm.com/downloads |
| **Docker Desktop** | 24.x | https://www.docker.com/products/docker-desktop |
| **Docker Compose** | v2.x (incluido en Docker Desktop) | — |
> **Nota para desarrolladores que quieran editar código localmente (sin Docker):**
> también necesitarás Python 3.11+ y Node.js 18+. Ver sección
> [Desarrollo local sin Docker](#desarrollo-local-sin-docker).
### Verificar que Docker esté corriendo
```bash
docker --version # Debe mostrar Docker version 24.x o superior
docker compose version # Debe mostrar Docker Compose version v2.x
```
Si `docker compose version` falla, prueba `docker-compose --version` (versión standalone).
---
## Inicio rápido con Docker (recomendado)
Este es el método más simple y funciona igual en **Windows, Linux y macOS**.
Solo necesitas Docker Desktop instalado y corriendo.
### Paso 1 — Clonar el repositorio
```bash
git clone https://git.aduanasoft.com/ADUANASOFT/service_manager.git
cd service_manager
```
### Paso 2 — Crear el archivo de variables de entorno
**Linux / macOS:**
```bash
cp .env.example .env
```
**Windows (PowerShell):**
```powershell
Copy-Item .env.example .env
```
**Windows (CMD):**
```cmd
copy .env.example .env
```
> **Importante:** El archivo `.env` nunca se sube a git (está en `.gitignore`).
> Para desarrollo local los valores del `.env.example` funcionan sin cambios.
> En producción **debes** generar claves secretas únicas (ver sección de variables de entorno).
### Paso 3 — Levantar todos los servicios
```bash
docker compose up -d
```
Este comando descarga las imágenes, construye los contenedores e inicia todo el stack.
La primera vez tarda entre 3 y 8 minutos dependiendo de la conexión a internet.
> **Alternativa con herramientas de desarrollo** (Adminer, MailHog, Redis Commander):
> ```bash
> docker compose --profile dev up -d
> ```
### Paso 4 — Verificar que todo esté funcionando
```bash
docker compose ps
```
Deberías ver todos los servicios con estado `Up` o `healthy`:
```
NAME STATUS
servicemanager-db Up (healthy)
servicemanager-redis Up (healthy)
servicemanager-backend Up (healthy)
servicemanager-worker Up
servicemanager-beat Up
servicemanager-client-frontend Up
servicemanager-internal-... Up
servicemanager-nginx Up
```
Si algún servicio muestra `Exit` o `Restarting`, revisa la sección
[Solución de problemas](#solución-de-problemas).
### Paso 5 — Cargar datos de ejemplo (opcional pero recomendado)
```bash
docker exec servicemanager-backend python /scripts/seed_data.py
```
Esto crea el tenant de demostración, categorías, usuarios y tickets de prueba.
### ¡Listo! Abre el navegador
| Aplicación | URL |
|------------|-----|
| Portal de clientes | http://localhost:3000 |
| Panel interno (staff) | http://localhost:3001 |
| API REST | http://localhost:8000 |
| Documentación API (Swagger) | http://localhost:8000/docs |
| Documentación API (ReDoc) | http://localhost:8000/redoc |
| Health check | http://localhost:8000/health |
> **Con perfil dev** activo también tendrás:
> - Adminer (gestor visual de PostgreSQL): http://localhost:8080
> - MailHog (pruebas de email): http://localhost:8025
> - Redis Commander (inspector de Redis): http://localhost:8081
---
## Configuración de variables de entorno
El archivo `.env` controla todo el comportamiento de la aplicación.
Copia `.env.example` como `.env` y revisa los valores siguientes:
### Variables críticas
| Variable | Descripción | Valor por defecto (dev) |
|----------|-------------|-------------------------|
| `SECRET_KEY` | Clave secreta general de Flask/FastAPI | _(cambiar en producción)_ |
| `JWT_SECRET_KEY` | Clave para firmar tokens JWT | _(cambiar en producción)_ |
| `DATABASE_URL` | Cadena de conexión a PostgreSQL | `postgresql+asyncpg://servicemanager:...@postgres:5432/servicemanager` |
| `REDIS_URL` | URL de conexión a Redis | `redis://redis:6379/0` |
| `ENVIRONMENT` | Entorno actual | `development` |
| `DEBUG` | Modo debug (muestra errores detallados) | `true` |
### Generar claves seguras para producción
**Linux / macOS:**
```bash
openssl rand -base64 32 # Genera SECRET_KEY
openssl rand -base64 32 # Genera JWT_SECRET_KEY
```
**Windows (PowerShell):**
```powershell
[Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 }))
```
> **Advertencia:** Nunca uses las claves del `.env.example` en producción.
> Cambiar las claves en producción invalida todas las sesiones activas.
### Desarrollo local vs Docker
En `.env.example` las URLs apuntan a nombres de servicio Docker (`postgres`, `redis`, `backend`).
Si ejecutas el backend directamente en tu máquina (sin Docker), cambia:
```dotenv
# Para desarrollo local sin Docker:
DATABASE_URL=postgresql+asyncpg://servicemanager:servicemanager123@localhost:5432/servicemanager
REDIS_URL=redis://localhost:6379/0
CELERY_BROKER_URL=redis://localhost:6379/0
```
---
## Cargar datos de prueba
El script `seed_data.py` crea datos iniciales en la base de datos.
**Con Docker (recomendado):**
```bash
docker exec servicemanager-backend python /scripts/seed_data.py
```
**Sin Docker:**
```bash
cd backend
python ../scripts/seed_data.py
```
El script crea:
- Tenant de demostración: `aduanasoft-demo`
- Categorías de tickets (Soporte Técnico, Facturación, Incidentes Críticos, etc.)
- Sistemas registrados
- Usuarios de prueba con distintos roles
---
## URLs y puertos por defecto
| Servicio | Puerto | Descripción |
|----------|--------|-------------|
| Frontend Clientes | **3000** | Portal para usuarios clientes |
| Frontend Interno | **3001** | Panel para staff (agentes, admins) |
| Backend API | **8000** | FastAPI — endpoints REST |
| PostgreSQL | **5432** | Base de datos (no exponer en producción) |
| Redis | **6379** | Cache y broker Celery (no exponer en producción) |
| Nginx | **80** | Reverse proxy |
| Adminer *(perfil dev)* | **8080** | GUI para PostgreSQL |
| MailHog *(perfil dev)* | **8025** | Capturador de emails en desarrollo |
| Redis Commander *(perfil dev)* | **8081** | GUI para Redis |
### ¿Conflicto de puertos?
Si algún puerto ya está en uso en tu máquina, edita `docker-compose.yml` y cambia
el número **izquierdo** del mapeo `host:container`. Por ejemplo, para backend en el 8080:
```yaml
ports:
- "8080:8000" # ahora accesible en localhost:8080
```
---
## Credenciales de prueba
Después de ejecutar el seed, puedes iniciar sesión con:
| Campo | Valor |
|-------|-------|
| Email | `admin@aduanasoft.com` |
| Contraseña | `admin123` |
| Tenant | `aduanasoft-demo` |
| Rol | `ADMIN` |
> Otros usuarios creados por el seed tienen el mismo sufijo de contraseña (`123`).
> Revisa `scripts/seed_data.py` para ver la lista completa.
---
## Desarrollo local sin Docker
Útil cuando necesitas depurar el código con breakpoints o acelerar el ciclo de desarrollo.
Requiere que **PostgreSQL y Redis sí corran en Docker** (o instalación nativa).
### Requisitos adicionales
| Herramienta | Versión | Descarga |
|------------|---------|---------|
| Python | 3.11 o 3.12 | https://www.python.org/downloads/ |
| Node.js (con npm) | 18 LTS | https://nodejs.org/ |
| pip | incluido con Python | — |
### Iniciar solo la base de datos y Redis
```bash
docker compose up -d postgres redis
```
### Backend (FastAPI)
```bash
cd backend
# Crear entorno virtual (solo la primera vez)
python -m venv ../.venv
# Activar entorno virtual
# Linux / macOS:
source ../.venv/bin/activate
# Windows (PowerShell):
..\.venv\Scripts\Activate.ps1
# Windows (CMD):
..\.venv\Scripts\activate.bat
# Instalar dependencias (solo la primera vez o cuando cambie requirements.txt)
pip install -r requirements.txt
# Ejecutar migraciones de base de datos
alembic upgrade head
# Iniciar servidor de desarrollo
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
> Si `uvicorn` no se encuentra, asegúrate de que el entorno virtual está activado
> (`(.venv)` debe aparecer en tu terminal).
### Frontend Clientes
```bash
cd frontend-client
# Instalar dependencias (solo la primera vez)
npm install
# Iniciar servidor de desarrollo en puerto 3000
npm run dev
```
### Frontend Interno (staff)
```bash
cd frontend-internal
# Instalar dependencias (solo la primera vez)
npm install
# Iniciar servidor de desarrollo en puerto 3001
npm run dev
```
> Los dos frontends tienen puertos distintos (3000 y 3001) para que no haya conflicto
> cuando corren al mismo tiempo.
### Workers Celery (opcional en desarrollo)
Necesario solo si desarrollas funcionalidades de notificaciones o SLAs automáticos.
```bash
cd workers
# Activar el mismo entorno virtual del backend:
# Linux / macOS:
source ../.venv/bin/activate
# Windows:
..\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
# Worker principal
celery -A app.celery worker --loglevel=info
# Scheduler de tareas periódicas (en otra terminal)
celery -A app.celery beat --loglevel=info --schedule=/tmp/celerybeat-schedule
```
---
## Arquitectura del proyecto
```
ServiceManagerWeb/
├── backend/ # FastAPI app
├── frontend-client/ # SvelteKit app para clientes
├── frontend-internal/ # SvelteKit app para staff interno
├── workers/ # Celery tasks
├── db/ # Migrations y esquemas
├── docker/ # Dockerfiles específicos
├── docs/ # Documentación adicional
├── scripts/ # Scripts de desarrollo/despliegue
├── docker-compose.yml # Orquestación completa
└── .env.example # Variables de entorno
├── backend/ # Aplicación FastAPI (Python 3.11)
│ ├── app/
│ │ ├── main.py # Punto de entrada, lifespan, middlewares
├── api/v1/
├── router.py # Registro de todos los routers
└── endpoints/ # Endpoints REST por dominio
│ │ ├── core/ # Config, seguridad, base de datos, caché
│ │ ├── models/ # Modelos SQLAlchemy (ORM)
│ │ ├── services/ # Lógica de negocio
│ │ └── middleware/ # Tenant context, Correlation ID
│ ├── migrations/ # Migraciones Alembic
│ ├── tests/ # Pruebas backend
│ └── requirements.txt # Dependencias Python
├── frontend-client/ # Portal de clientes (SvelteKit + TypeScript)
│ └── src/routes/ # Páginas: login, tickets, perfil
├── frontend-internal/ # Panel de staff (SvelteKit + TypeScript)
│ └── src/routes/ # Páginas: dashboard, tickets, reportes, auditoría
├── workers/ # Tareas asíncronas Celery
│ └── app/tasks/ # email_tasks.py, sla_tasks.py, etc.
├── docker/ # Dockerfiles y configuración Nginx
├── db/ # schema.sql inicial
├── docs/ # Documentación técnica adicional
├── scripts/ # seed_data.py, setup-dev.sh, etc.
├── docker-compose.yml # Orquestación completa
└── .env.example # Plantilla de variables de entorno
```
## Stack Tecnológico
### Stack tecnológico
### Backend (Python)
- FastAPI (async)
- Pydantic v2
- SQLAlchemy 2.0 (async)
- Alembic (migrations)
- Argon2 (hashing passwords)
- PyJWT
- Celery + Redis
**Backend:** Python 3.11 · FastAPI · Pydantic v2 · SQLAlchemy 2.0 (async) · Alembic · Argon2 · PyJWT · Celery · Redis
### Frontend (JavaScript/TypeScript)
- SvelteKit
- TypeScript
- TailwindCSS
- shadcn/ui o similar
- Zod (validación)
**Frontend:** Node.js 18 · SvelteKit · TypeScript · TailwindCSS · Zod
### Infraestructura
- PostgreSQL 15+
- Redis 7+
- Docker & Docker Compose
- Nginx (reverse proxy)
**Infraestructura:** PostgreSQL 15 · Redis 7 · Docker Compose · Nginx
## Dominios del Sistema
---
1. **Auth**: Usuarios, roles, permisos, 2FA
2. **Tenants**: Multi-tenancy, organizaciones
3. **Tickets**: Gestión de tickets, estados, SLAs
4. **Notifications**: Email, plantillas, logs
5. **Audit**: Bitácora de acciones
## Roles y permisos
## Roles de Usuario
### Internos (Staff)
- `ADMIN`: Control total del sistema
- `SUPPORT_MANAGER`: Gestión de equipos y SLAs
- `AGENT`: Atención de tickets
- `AUDITOR`: Solo lectura para auditoría
### Personal interno (staff)
| Rol | Descripción |
|-----|-------------|
| `ADMIN` | Control total del sistema |
| `SUPPORT_MANAGER` | Gestión de equipos y configuración de SLAs |
| `AGENT` | Atención y resolución de tickets |
| `AUDITOR` | Solo lectura para revisiones y cumplimiento |
### Clientes
- `CLIENT_ADMIN`: Gestión de organización cliente
- `CLIENT_USER`: Creación y seguimiento de tickets
| Rol | Descripción |
|-----|-------------|
| `CLIENT_ADMIN` | Gestión de su organización cliente |
| `CLIENT_USER` | Creación y seguimiento de sus propios tickets |
## Quick Start
---
## Comandos útiles
### Docker Compose
```bash
# Clonar y configurar
git clone <repo>
cd ServiceManagerWeb
cp .env.example .env
# Levantar todos los servicios (segundo plano)
docker compose up -d
# Levantar servicios
docker-compose up -d
# Levantar con herramientas de desarrollo
docker compose --profile dev up -d
# Verificar estado
docker-compose ps
# Ver logs en tiempo real de todos los servicios
docker compose logs -f
# Ver logs de un servicio específico
docker compose logs -f backend
docker compose logs -f frontend-internal
# Detener todos los servicios (mantiene los datos)
docker compose down
# Detener Y borrar todos los volúmenes (¡borra la base de datos!)
docker compose down -v
# Reconstruir imagen de un servicio (después de cambiar Dockerfile o requirements)
docker compose build backend
docker compose up -d backend
# Reiniciar un servicio
docker compose restart backend
```
## URLs por Defecto
- Frontend Clientes: http://localhost:3000
- Frontend Interno: http://localhost:3001
- API Backend: http://localhost:8000
- API Docs: http://localhost:8000/docs
- Adminer (DB): http://localhost:8080
## Scripts de Desarrollo
### Base de datos (Alembic)
```bash
# Backend
# Aplicar todas las migraciones pendientes
cd backend
python -m uvicorn app.main:app --reload --port 8000
alembic upgrade head
# Frontend Cliente
cd frontend-client
npm run dev -- --port 3000
# Ver estado de migraciones
alembic current
# Frontend Interno
cd frontend-internal
npm run dev -- --port 3001
# Revertir última migración
alembic downgrade -1
# Workers
cd workers
celery -A app.worker worker --loglevel=info
celery -A app.worker beat --loglevel=info
# Crear nueva migración (después de modificar models/)
alembic revision --autogenerate -m "nombre descriptivo del cambio"
# Con Docker:
docker exec servicemanager-backend alembic upgrade head
```
## Testing
### Calidad de código
```bash
# Backend tests
cd backend
# Linter y auto-fix
ruff check . --fix
# Formateador
black .
# Verificación de tipos
mypy .
# Todo de una vez
ruff check . --fix && black . && mypy .
```
---
## Pruebas (testing)
### Backend
```bash
cd backend
# Ejecutar todas las pruebas
pytest
# Frontend tests
cd frontend-client
npm test
cd ../frontend-internal
npm test
# Con cobertura detallada
pytest --cov=app --cov-report=html
# Abrir reporte de cobertura (Linux/macOS)
open htmlcov/index.html
# Windows
start htmlcov/index.html
# Prueba específica
pytest tests/test_auth.py -v
# Con Docker
docker exec servicemanager-backend pytest -v --cov=app
```
## Troubleshooting
### Error 500 en Login / Proxy Error
**Síntoma**: Error 500 al intentar hacer login, o error de proxy de Vite "connect ECONNREFUSED".
**Causa**: Configuración incorrecta de la comunicación entre servicios de Docker.
**Solución**:
1. En desarrollo con Docker, los servicios usan nombres de servicio (no `localhost`)
2. Verificar `vite.config.js`: el proxy debe apuntar a `http://backend:8000`
3. Verificar `docker-compose.yml`: `PUBLIC_API_URL` debe ser `http://backend:8000`
4. Después de cambios, reiniciar contenedor: `docker-compose restart frontend-internal`
**Nota**: Para desarrollo local sin Docker, cambiar el proxy a `http://localhost:8000`.
### Tenant Slug Incorrecto
**Síntoma**: Error de autenticación incluso con credenciales correctas.
**Causa**: El `tenant_slug` en el login no coincide con los tenants en la BD.
**Solución**:
1. Verificar tenants existentes: `docker exec servicemanager-backend python check_tenants.py`
2. Actualizar el tenant_slug en el código de login
3. Tenants por defecto: `aduanasoft-demo`, `test-tenant`
### Credenciales de Prueba
### Frontend
```bash
cd frontend-internal # o frontend-client
npm test # Ejecutar una vez
npm run test:watch # Modo observador
```
Email: admin@aduanasoft.com
Password: admin123
Tenant: aduanasoft-demo
Role: ADMIN
---
## Solución de problemas
### El backend no inicia — error en `DATABASE_URL`
**Síntoma:** El contenedor `servicemanager-backend` reinicia continuamente.
**Causa frecuente:** El archivo `.env` no existe o tiene `DATABASE_URL` apuntando a `localhost`
en lugar del nombre del servicio Docker `postgres`.
**Solución:**
```bash
# Verificar que .env existe
ls .env # Linux/macOS
dir .env # Windows
# Si no existe, crearlo
cp .env.example .env # Linux/macOS
Copy-Item .env.example .env # Windows PowerShell
# Verificar el valor correcto en .env:
# DATABASE_URL=postgresql+asyncpg://servicemanager:servicemanager123@postgres:5432/servicemanager
# ^^^^^^^
# Nombre de servicio Docker, NO localhost
```
---
### Error 500 en login / "connect ECONNREFUSED"
**Síntoma:** El frontend muestra error 500 al hacer login, o la consola del navegador
muestra `ECONNREFUSED 127.0.0.1:8000`.
**Causa:** El proxy de Vite no encuentra el backend.
**Solución en Docker:** El proxy ya está configurado para usar `PUBLIC_API_URL`.
Verifica en `docker-compose.yml` que `frontend-internal` y `frontend-client` tienen:
```yaml
environment:
- PUBLIC_API_URL=http://backend:8000
```
Después reinicia:
```bash
docker compose restart frontend-internal frontend-client
```
**Solución en desarrollo local:** Asegúrate de que el backend está corriendo:
```bash
curl http://localhost:8000/health
# Debe responder: {"status": "ok", ...}
```
---
### El frontend-internal y frontend-client usan el mismo puerto localmente
**Síntoma:** Al correr ambos frontends sin Docker, uno de los dos falla
con `Port 3000 is already in use`.
**Solución:**
- `frontend-client` → usa el puerto **3000** (por defecto con `npm run dev`)
- `frontend-internal` → usa el puerto **3001** (configurado en `vite.config.js`)
Nunca hay conflicto si los iniciaste con `npm run dev` en cada carpeta por separado.
Si aún hay conflicto, mata el proceso en ese puerto:
```bash
# Linux / macOS
lsof -ti:3000 | xargs kill -9
# Windows (PowerShell)
Get-Process -Id (Get-NetTCPConnection -LocalPort 3000).OwningProcess | Stop-Process -Force
```
---
### El tenant slug es incorrecto al hacer login
**Síntoma:** Login falla con "credenciales inválidas" aunque el email y contraseña son correctos.
**Causa:** El campo `tenant_slug` no corresponde a ningún tenant en la base de datos.
**Solución:**
```bash
# Ver los tenants disponibles
docker exec servicemanager-backend python -c "
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy import text
import os
async def main():
engine = create_async_engine(os.environ['DATABASE_URL'])
async with AsyncSession(engine) as s:
result = await s.execute(text('SELECT slug, name FROM tenants'))
for row in result:
print(row)
asyncio.run(main())
"
```
Tenant por defecto (después del seed): **`aduanasoft-demo`**
---
### Puerto ocupado — cambiar puertos de los servicios
Edita `docker-compose.yml` y modifica **solo el número izquierdo** del mapeo de puertos:
```yaml
# Ejemplo: mover el backend al puerto 9000
backend:
ports:
- "9000:8000" # accesible en localhost:9000
# Ejemplo: mover el frontend al puerto 4000
frontend-client:
ports:
- "4000:3000" # accesible en localhost:4000
```
---
### Migraciones fallidas — `alembic upgrade head` da error
```bash
# Verificar el estado actual
docker exec servicemanager-backend alembic current
# Si hay conflicto, hacer downgrade hasta la base y volver a subir
docker exec servicemanager-backend alembic downgrade base
docker exec servicemanager-backend alembic upgrade head
```
---
### Módulo Python no encontrado (`ModuleNotFoundError`)
**Con Docker:** El módulo no está en `requirements.txt` o la imagen no fue reconstruida.
```bash
# Reconstruir la imagen del backend
docker compose build backend
docker compose up -d backend
```
**Local:** El entorno virtual no está activado.
```bash
# Verificar que el venv está activo (debe aparecer (.venv) en el prompt)
which python # Linux/macOS — debe apuntar a .venv/
# Windows:
where python # debe apuntar a .venv\Scripts\python.exe
```
---
### `npm: command not found` o versión de Node incorrecta
```bash
node --version # Debe ser v18.x o superior
npm --version # Debe ser 9.x o superior
```
Si Node no está instalado, descárgalo desde https://nodejs.org/ (elige "LTS").
En macOS con Homebrew:
```bash
brew install node@18
```
En Linux (Ubuntu/Debian):
```bash
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs
```
---
### `docker-compose` no se reconoce como comando
En versiones modernas de Docker Desktop, el comando es `docker compose` (con espacio, sin guion).
Si tienes instalación separada de Docker Compose v1, usa `docker-compose` (con guion).
---
### Logs de los contenedores
```bash
# Ver qué está fallando
docker compose logs backend --tail=50
docker compose logs frontend-internal --tail=50
docker compose logs postgres --tail=20
```
---
## Contribución
1. Fork del proyecto
2. Crear feature branch (`git checkout -b feature/nueva-funcionalidad`)
3. Commit cambios (`git commit -am 'Agregar nueva funcionalidad'`)
4. Push a branch (`git push origin feature/nueva-funcionalidad`)
5. Crear Pull Request
1. Haz fork del proyecto
2. Crea una rama de funcionalidad: `git checkout -b feature/nombre-funcionalidad`
3. Realiza tus cambios siguiendo las convenciones del proyecto
4. Ejecuta las pruebas: `pytest` y el linter: `ruff check .`
5. Haz commit con un mensaje descriptivo: `git commit -m "feat: agregar exportación a CSV"`
6. Sube tu rama: `git push origin feature/nombre-funcionalidad`
7. Abre un Pull Request hacia `main`
### Convenciones de nombres
- **Modelos**: `PascalCase``User`, `Ticket`, `TenantOrganization`
- **Endpoints (URL)**: `kebab-case``/api/v1/user-management/`
- **Componentes Svelte**: `PascalCase.svelte``TicketCard.svelte`
- **Stores**: `camelCase``ticketStore.ts`
---
## Historial de versiones
| Versión | Descripción |
|---------|-------------|
| **v1.15.1** | Módulo de reportes implementado |
| v1.14.x | Mejoras al módulo de auditoría |
| v1.13.x | Sistema de SLAs automático |
| v1.12.x | Notificaciones por email |
| v1.0.0 | MVP inicial — tickets, tenants, autenticación |
---
## Licencia
Propietario - Aduanasoft © 2026
Propietario Aduanasoft © 2026. Todos los derechos reservados.

View File

@@ -0,0 +1,227 @@
"""
Reports Schemas - ServiceManagerWeb
Schemas de respuesta para el módulo de reportes y estadísticas.
"""
from pydantic import BaseModel, ConfigDict
from typing import Optional, List, Dict, Any
from datetime import datetime
# ===================================
# RESUMEN GENERAL
# ===================================
class TicketsByStatus(BaseModel):
"""Conteo de tickets agrupado por estado"""
new: int = 0
triage: int = 0
in_progress: int = 0
waiting_customer: int = 0
resolved: int = 0
closed: int = 0
reopened: int = 0
total: int = 0
class TicketsByPriority(BaseModel):
"""Conteo de tickets agrupado por prioridad"""
low: int = 0
medium: int = 0
high: int = 0
urgent: int = 0
total: int = 0
class ReportSummaryResponse(BaseModel):
"""Resumen ejecutivo del período seleccionado"""
period_start: datetime
period_end: datetime
generated_at: datetime
# Totales del período
total_tickets: int
open_tickets: int # Tickets sin resolver
resolved_tickets: int # Tickets resueltos o cerrados
avg_resolution_hours: Optional[float] # Promedio de horas para resolver
avg_first_response_hours: Optional[float] # Promedio de horas para primera respuesta
# Satisfacción del cliente
avg_rating: Optional[float] # Promedio de calificación (1-5)
total_rated: int # Cuántos tickets tienen calificación
# Desglose por estado y prioridad
by_status: TicketsByStatus
by_priority: TicketsByPriority
# Comparación vs período anterior
tickets_change_pct: Optional[float] # % cambio vs período anterior
resolution_change_pct: Optional[float] # % cambio en tasa de resolución
model_config = ConfigDict(from_attributes=True)
# ===================================
# RENDIMIENTO POR AGENTE
# ===================================
class AgentReportRow(BaseModel):
"""Estadísticas de un agente específico"""
agent_id: str
agent_name: str
agent_email: str
total_assigned: int # Total asignados en el período
resolved: int # Cuántos resolvió
open: int # Cuántos siguen abiertos
resolution_rate: float # Porcentaje de resolución (0-100)
avg_resolution_hours: Optional[float] # Promedio de horas para resolver
avg_rating: Optional[float] # Calificación promedio (1-5)
total_rated: int # Cuántos tickets calificaron al agente
urgent_handled: int # Urgentes atendidos
class AgentReportResponse(BaseModel):
"""Reporte de rendimiento por agente"""
period_start: datetime
period_end: datetime
generated_at: datetime
agents: List[AgentReportRow]
total_agents: int
model_config = ConfigDict(from_attributes=True)
# ===================================
# TICKETS POR CATEGORÍA
# ===================================
class CategoryReportRow(BaseModel):
"""Estadísticas de una categoría"""
category_id: str
category_name: str
total_tickets: int
open_tickets: int
resolved_tickets: int
avg_resolution_hours: Optional[float]
sla_response_hours: int # SLA configurado para respuesta
sla_resolution_hours: int # SLA configurado para resolución
sla_compliance_pct: float # % de tickets que cumplieron SLA de resolución
class CategoryReportResponse(BaseModel):
"""Reporte de tickets agrupado por categoría"""
period_start: datetime
period_end: datetime
generated_at: datetime
categories: List[CategoryReportRow]
uncategorized_count: int
model_config = ConfigDict(from_attributes=True)
# ===================================
# TICKETS POR CLIENTE (TENANT)
# ===================================
class ClientReportRow(BaseModel):
"""Estadísticas de un cliente (tenant)"""
tenant_id: str
tenant_name: str
total_tickets: int
open_tickets: int
resolved_tickets: int
urgent_tickets: int
avg_resolution_hours: Optional[float]
avg_rating: Optional[float]
last_ticket_at: Optional[datetime]
class ClientReportResponse(BaseModel):
"""Reporte de tickets agrupado por cliente — solo ADMIN"""
period_start: datetime
period_end: datetime
generated_at: datetime
clients: List[ClientReportRow]
total_clients: int
model_config = ConfigDict(from_attributes=True)
# ===================================
# TENDENCIAS (TICKETS EN EL TIEMPO)
# ===================================
class TrendDataPoint(BaseModel):
"""Un punto de datos en la línea de tendencia"""
date: str # Formato YYYY-MM-DD
created: int # Tickets creados ese día
resolved: int # Tickets resueltos ese día
net_open: int # Diferencia: creados - resueltos
class TrendsReportResponse(BaseModel):
"""Evolución de tickets día a día"""
period_start: datetime
period_end: datetime
generated_at: datetime
data_points: List[TrendDataPoint]
total_days: int
model_config = ConfigDict(from_attributes=True)
# ===================================
# SATISFACCIÓN DEL CLIENTE (CSAT)
# ===================================
class CSATDistribution(BaseModel):
"""Distribución de calificaciones 1-5"""
rating_1: int = 0
rating_2: int = 0
rating_3: int = 0
rating_4: int = 0
rating_5: int = 0
class CSATReportResponse(BaseModel):
"""Reporte de satisfacción del cliente"""
period_start: datetime
period_end: datetime
generated_at: datetime
avg_rating: Optional[float]
total_rated: int
total_tickets: int
response_rate: float # % de tickets que recibieron calificación
distribution: CSATDistribution
by_category: List[Dict[str, Any]] # Promedio por categoría
by_agent: List[Dict[str, Any]] # Promedio por agente
recent_comments: List[Dict[str, Any]] = [] # Últimos comentarios de calificación
model_config = ConfigDict(from_attributes=True)
# ===================================
# TICKETS POR SISTEMA AFECTADO
# ===================================
class SystemReportRow(BaseModel):
"""Estadísticas de un sistema afectado"""
system_id: str
system_name: str
total_tickets: int
open_tickets: int
resolved_tickets: int
urgent_tickets: int
avg_resolution_hours: Optional[float]
class SystemReportResponse(BaseModel):
"""Reporte de tickets agrupado por sistema afectado"""
period_start: datetime
period_end: datetime
generated_at: datetime
systems: List[SystemReportRow]
no_system_count: int # Tickets sin sistema asignado
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,8 +1,34 @@
"""Helper functions for audit endpoints"""
"""
Audit Helpers - ServiceManagerWeb
===================================
Funciones auxiliares reutilizables para los endpoints de auditoría.
Este archivo contiene:
- audit_log_to_dict: Convierte un modelo AuditLog a diccionario
- apply_tenant_filter: Aplica filtro de tenant según permisos
- get_count_stat: Cuenta registros con filtros opcionales (CORREGIDO)
- get_top_items: Obtiene los items más frecuentes
- detect_mass_deletions: Detecta eliminaciones masivas sospechosas
- detect_brute_force: Detecta ataques de fuerza bruta
- detect_privilege_escalation: Detecta escaladas de privilegios
CORRECCIÓN APLICADA en get_count_stat:
La columna created_at en PostgreSQL es 'timestamp with time zone' (TIMESTAMPTZ),
lo que significa que almacena y devuelve fechas CON información de timezone (+00).
El bug era que se comparaba un datetime naive (sin timezone) contra una columna
TIMESTAMPTZ. PostgreSQL no puede comparar ambos tipos directamente, por lo que
el filtro se ignoraba silenciosamente y los tres contadores devolvían el mismo
valor (el total histórico completo sin ningún filtro de fecha).
La solución es garantizar que TODAS las fechas que se usen en queries tengan
timezone info (aware datetime en UTC) usando _ensure_aware_utc().
"""
from sqlalchemy import select, func, and_, or_, desc
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional, Dict, List
from datetime import datetime
from datetime import datetime, timezone
import uuid
from app.models.audit import AuditLog
@@ -10,8 +36,18 @@ from app.models.user import User, UserRole
from app.models.tenant import Tenant
# =============================================================================
# CONVERSIÓN DE MODELOS
# =============================================================================
def audit_log_to_dict(log: AuditLog) -> dict:
"""Convierte AuditLog a diccionario de respuesta"""
"""
Convierte un objeto AuditLog de SQLAlchemy a un diccionario plano
compatible con los schemas de respuesta de Pydantic.
Incluye los datos del usuario relacionado si están cargados
(requiere que la query use selectinload(AuditLog.user)).
"""
log_dict = {
"id": log.id,
"tenant_id": log.tenant_id,
@@ -19,47 +55,145 @@ def audit_log_to_dict(log: AuditLog) -> dict:
"action": log.action,
"resource_type": log.resource_type,
"resource_id": log.resource_id,
# ip_address puede ser un objeto especial de PostgreSQL, convertir a string
"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,
# extra_metadata evita conflicto con la palabra reservada 'metadata'
"metadata": log.extra_metadata,
"created_at": log.created_at,
"action_display": log.action_display,
# Campos del usuario (se llenan abajo si la relación está cargada)
"user_email": None,
"user_name": None
"user_name": None,
"user_role": None,
}
# Solo agregar datos del usuario si la relación fue cargada en la query
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)
# El rol puede ser un Enum de Python o un string, manejar ambos casos
log_dict["user_role"] = (
log.user.role.value
if hasattr(log.user.role, 'value')
else str(log.user.role)
)
return log_dict
def apply_tenant_filter(query, current_user: User, current_tenant: Tenant, all_tenants: bool = False, specific_tenant_id: Optional[uuid.UUID] = None):
"""Aplica filtro de tenant según permisos del usuario"""
# =============================================================================
# FILTRO DE MULTI-TENANCY
# =============================================================================
def apply_tenant_filter(
query,
current_user: User,
current_tenant: Tenant,
all_tenants: bool = False,
specific_tenant_id: Optional[uuid.UUID] = None
):
"""
Aplica el filtro de tenant a una query de SQLAlchemy según los
permisos del usuario actual.
Reglas:
- ADMIN y SUPPORT_MANAGER pueden ver todos los tenants si
all_tenants=True, o filtrar por un tenant específico.
- Cualquier otro rol solo puede ver los datos de su propio tenant.
"""
can_see_all_tenants = current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]
if all_tenants and can_see_all_tenants:
return query # No filtrar por tenant
# Usuario privilegiado pidiendo ver todos los tenants → sin filtro
return query
elif specific_tenant_id and can_see_all_tenants:
# Usuario privilegiado pidiendo un tenant específico
return query.where(AuditLog.tenant_id == specific_tenant_id)
else:
# Cualquier otro caso → solo ver el propio tenant
return query.where(AuditLog.tenant_id == current_tenant.id)
async def get_count_stat(db: AsyncSession, tenant_id: Optional[uuid.UUID] = None,
date_from: Optional[datetime] = None, action_filter=None) -> int:
"""Obtiene estadística de conteo con filtros opcionales"""
# =============================================================================
# UTILIDAD DE FECHAS
# =============================================================================
def _ensure_aware_utc(dt: datetime) -> datetime:
"""
Garantiza que un datetime tenga información de timezone en UTC.
PROBLEMA QUE RESUELVE:
La columna created_at en PostgreSQL es 'timestamp with time zone'
(TIMESTAMPTZ). Cuando se compara con un datetime naive (sin timezone),
PostgreSQL no puede hacer la comparación correctamente y el filtro
de fecha se ignora silenciosamente, devolviendo todos los registros
sin importar la fecha.
SOLUCIÓN:
Siempre convertir las fechas a aware UTC antes de usarlas en queries.
Casos que maneja:
- datetime naive (sin tzinfo): agrega UTC como timezone
- datetime aware (con tzinfo): convierte a UTC si es otra zona horaria
Ejemplos:
datetime(2026, 2, 24, 15, 0, 0) → datetime(2026, 2, 24, 15, 0, 0, tzinfo=UTC)
datetime(2026, 2, 24, 9, 0, 0, tzinfo=CST) → datetime(2026, 2, 24, 15, 0, 0, tzinfo=UTC)
"""
if dt.tzinfo is None:
# Datetime naive → asumir que ya es UTC y agregarle timezone info
return dt.replace(tzinfo=timezone.utc)
else:
# Datetime aware → convertir a UTC (por si viene en otra zona horaria)
return dt.astimezone(timezone.utc)
# =============================================================================
# CONTADORES DE ESTADÍSTICAS
# =============================================================================
async def get_count_stat(
db: AsyncSession,
tenant_id: Optional[uuid.UUID] = None,
date_from: Optional[datetime] = None,
action_filter=None
) -> int:
"""
Cuenta registros de AuditLog con filtros opcionales.
Usado por get_audit_stats() para calcular:
- total_actions: Sin date_from → cuenta todos los registros
- actions_today: date_from = now - 24h → registros del día
- actions_this_week: date_from = now - 7d → registros de la semana
CORRECCIÓN: Las fechas se convierten a aware UTC con _ensure_aware_utc()
antes de usarlas en la query, para que sean compatibles con la columna
TIMESTAMPTZ de PostgreSQL y el filtro se aplique correctamente.
Args:
db: Sesión de base de datos
tenant_id: Si se especifica, filtra por ese tenant
date_from: Si se especifica, solo cuenta registros desde esa fecha
action_filter: Condición SQLAlchemy adicional opcional
Returns:
Número entero de registros que cumplen los filtros
"""
query = select(func.count()).select_from(AuditLog)
if tenant_id:
query = query.where(AuditLog.tenant_id == tenant_id)
if date_from:
query = query.where(AuditLog.created_at >= date_from)
# CORRECCIÓN: convertir a aware UTC para compatibilidad con TIMESTAMPTZ
# Sin esto, el filtro se ignora y los tres contadores son idénticos
date_from_aware = _ensure_aware_utc(date_from)
query = query.where(AuditLog.created_at >= date_from_aware)
if action_filter is not None:
query = query.where(action_filter)
@@ -67,21 +201,52 @@ async def get_count_stat(db: AsyncSession, tenant_id: Optional[uuid.UUID] = None
return result.scalar() or 0
async def get_top_items(db: AsyncSession, field, tenant_id: Optional[uuid.UUID] = None,
limit: int = 5, join_user: bool = False) -> Dict[str, int]:
"""Obtiene top items por campo con conteo"""
# =============================================================================
# ITEMS MÁS FRECUENTES
# =============================================================================
async def get_top_items(
db: AsyncSession,
field,
tenant_id: Optional[uuid.UUID] = None,
limit: int = 5,
join_user: bool = False
) -> Dict[str, int]:
"""
Obtiene los valores más frecuentes de un campo, ordenados por conteo.
Ejemplos de uso:
- get_top_items(db, AuditLog.action, ...) → {"ticket.create": 45}
- get_top_items(db, AuditLog.resource_type, ...) → {"ticket": 60}
- get_top_items(db, None, ..., join_user=True) → {"admin@empresa.com": 40}
Args:
db: Sesión de base de datos
field: Campo de AuditLog por el que agrupar
tenant_id: Si se especifica, filtra por ese tenant
limit: Máximo de resultados a devolver (por defecto 5)
join_user: Si True, agrupa por email de usuario
Returns:
Diccionario {valor: conteo} ordenado de mayor a menor
"""
if join_user:
query = select(User.email, func.count(AuditLog.id).label('count')).join(User, AuditLog.user_id == User.id)
# Modo usuarios: hacer JOIN con tabla User y agrupar por email
query = (
select(User.email, func.count(AuditLog.id).label('count'))
.join(User, AuditLog.user_id == User.id)
)
else:
# Modo campo: agrupar por el campo especificado
query = select(field, func.count(AuditLog.id).label('count'))
if tenant_id:
query = query.where(AuditLog.tenant_id == tenant_id)
if not join_user:
query = query.group_by(field)
else:
if join_user:
query = query.group_by(User.email)
else:
query = query.group_by(field)
query = query.order_by(desc('count')).limit(limit)
@@ -89,8 +254,27 @@ async def get_top_items(db: AsyncSession, field, tenant_id: Optional[uuid.UUID]
return {row[0]: row[1] for row in result}
# =============================================================================
# DETECTORES DE INCIDENTES DE SEGURIDAD
# =============================================================================
def detect_mass_deletions(logs: List[AuditLog], now: datetime) -> List[dict]:
"""Detecta eliminaciones masivas de logs de auditoría"""
"""
Detecta patrones de eliminación masiva agrupando por usuario y día.
Lógica:
- Agrupa todos los logs de eliminación por (usuario, día)
- Si un usuario eliminó >= 3 recursos en un día, genera un incidente
- La severidad escala según la cantidad:
- >= 3 eliminaciones → medium
- >= 5 eliminaciones → high
- >= 10 eliminaciones → critical
El estado del incidente es:
- "active": si la última eliminación fue hace menos de 24 horas
- "resolved": si fue hace más de 24 horas
"""
# Agrupar eliminaciones por usuario y día
deletion_groups = {}
for log in logs:
@@ -98,10 +282,15 @@ def detect_mass_deletions(logs: List[AuditLog], now: datetime) -> List[dict]:
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
'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
@@ -110,35 +299,74 @@ def detect_mass_deletions(logs: List[AuditLog], now: datetime) -> List[dict]:
deletion_groups[key]['last_seen'] = max(deletion_groups[key]['last_seen'], log.created_at)
incidents = []
for key, group in deletion_groups.items():
if group['count'] >= 3:
severity = "critical" if group['count'] >= 10 else "high" if group['count'] >= 5 else "medium"
status = "active" if (now - group['last_seen']).days <= 1 else "resolved"
incidents.append({
"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": status,
"incident_type": "mass_deletion",
"affected_user": group['user'],
"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'],
"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']
})
for key, group in deletion_groups.items():
if group['count'] < 3:
continue
if group['count'] >= 10:
severity = "critical"
elif group['count'] >= 5:
severity = "high"
else:
severity = "medium"
# Convertir ambas fechas a aware UTC para comparación segura
now_aware = _ensure_aware_utc(now)
last_seen_aware = _ensure_aware_utc(group['last_seen'])
hours_since_last = (now_aware - last_seen_aware).total_seconds() / 3600
incident_status = "active" if hours_since_last <= 24 else "resolved"
incidents.append({
"id": f"mass_del_{key.replace('_', '-')}",
"title": f"Eliminaciones masivas - {group['user']}",
"description": (
f"{group['user']} elimino {group['count']} elementos "
f"el {group['date']}"
),
"severity": severity,
"status": incident_status,
"incident_type": "mass_deletion",
"affected_user": group['user'],
"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'],
"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']
})
return incidents
def detect_brute_force(logs: List[AuditLog], now: datetime) -> List[dict]:
"""Detecta ataques de fuerza bruta de logs de login fallido"""
"""
Detecta ataques de fuerza bruta agrupando intentos fallidos por IP.
Lógica:
- Agrupa todos los intentos fallidos de login por dirección IP
- Si una IP tiene >= 5 intentos, genera un incidente
- La severidad escala según la cantidad:
- >= 5 intentos → medium
- >= 10 intentos → high
- >= 20 intentos → critical
El estado del incidente es:
- "active": si el último intento fue hace menos de 24 horas
- "investigating": si fue hace más de 24 horas
"""
ip_groups = {}
for log in logs:
@@ -146,47 +374,99 @@ def detect_brute_force(logs: List[AuditLog], now: datetime) -> List[dict]:
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': 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)
ip_groups[ip]['first_seen'] = min(ip_groups[ip]['first_seen'], log.created_at)
ip_groups[ip]['last_seen'] = max(ip_groups[ip]['last_seen'], log.created_at)
if log.user and log.user.email:
ip_groups[ip]['users'].add(log.user.email)
incidents = []
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"
status = "active" if (now - group['last_seen']).total_seconds() <= 86400 else "investigating"
incidents.append({
"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": status,
"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']
})
for ip, group in ip_groups.items():
if group['count'] < 5:
continue
if group['count'] >= 20:
severity = "critical"
elif group['count'] >= 10:
severity = "high"
else:
severity = "medium"
# Convertir ambas fechas a aware UTC para comparación segura
now_aware = _ensure_aware_utc(now)
last_seen_aware = _ensure_aware_utc(group['last_seen'])
seconds_since_last = (now_aware - last_seen_aware).total_seconds()
incident_status = "active" if seconds_since_last <= 86400 else "investigating"
incidents.append({
"id": f"brute_force_{ip.replace('.', '-')}",
"title": f"Posible ataque de fuerza bruta desde {ip}",
"description": (
f"Se detectaron {group['count']} intentos fallidos de "
f"login desde la IP {ip}"
),
"severity": severity,
"status": incident_status,
"incident_type": "brute_force_attack",
"affected_user": (
', '.join(list(group['users'])[:3])
if group['users']
else None
),
"source_ip": ip,
"evidence": [
f"Login fallido - "
f"{log.user.email if log.user else 'Desconocido'} - "
f"{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']
})
return incidents
def detect_privilege_escalation(logs: List[AuditLog]) -> List[dict]:
"""Detecta escaladas de privilegios"""
role_hierarchy = {'CLIENT_USER': 1, 'CLIENT_ADMIN': 2, 'AGENT': 3, 'SUPPORT_MANAGER': 4, 'ADMIN': 5}
"""
Detecta escaladas de privilegios comparando el rol anterior y nuevo.
Lógica:
- Analiza cada log de cambio de rol (user.update con campo 'role')
- Si el nuevo rol tiene más privilegios que el anterior, es sospechoso
- Cada cambio que represente una escalada genera un incidente
Jerarquía de roles (de menor a mayor privilegio):
CLIENT_USER(1) < CLIENT_ADMIN(2) < AGENT(3) < SUPPORT_MANAGER(4) < ADMIN(5)
"""
role_hierarchy = {
'CLIENT_USER': 1,
'CLIENT_ADMIN': 2,
'AGENT': 3,
'SUPPORT_MANAGER': 4,
'ADMIN': 5
}
incidents = []
for log in logs:
@@ -195,27 +475,43 @@ def detect_privilege_escalation(logs: List[AuditLog]) -> List[dict]:
old_role = log.old_values.get('role') if log.old_values else 'Unknown'
new_role = log.new_values.get('role')
old_level = role_hierarchy.get(old_role, 0)
new_level = role_hierarchy.get(new_role, 0)
if new_level > old_level:
incidents.append({
"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": 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,
"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
})
# Solo generar incidente si el nuevo rol tiene MÁS privilegios
if new_level <= old_level:
continue
severity = "high" if new_role in ['ADMIN', 'SUPPORT_MANAGER'] else "medium"
incidents.append({
"id": f"priv_esc_{log.id}",
"title": f"Escalada de privilegios - {log.user.email}",
"description": (
f"Usuario {log.user.email} cambio de rol "
f"{old_role} a {new_role}"
),
"severity": severity,
"status": "investigating",
"incident_type": "privilege_escalation",
"affected_user": log.user.email,
"source_ip": str(log.ip_address) if log.ip_address else None,
"evidence": [
f"Cambio de rol: {old_role}{new_role} - "
f"{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
})
return incidents

View File

@@ -1,4 +1,29 @@
"""Audit Endpoints - ServiceManagerWeb"""
"""
Audit Endpoints - ServiceManagerWeb
====================================
Este archivo maneja todos los endpoints de auditoría y seguridad.
Rutas disponibles:
GET /audit/ → Lista de logs con filtros y paginación
GET /audit/stats → Estadísticas generales de auditoría
GET /audit/{log_id} → Detalle de un log específico
GET /audit/security/analysis → Análisis de amenazas en tiempo real
POST /audit/security/action → Ejecutar acción de seguridad (bloquear IP, etc.)
GET /audit/security/incidents → Lista de incidentes detectados
CORRECCIONES APLICADAS:
1. Todos los endpoints usan datetime.now(timezone.utc) para generar
fechas aware (con timezone info en UTC), compatibles con la columna
'timestamp with time zone' (TIMESTAMPTZ) de PostgreSQL.
2. audit_helpers.get_count_stat() convierte las fechas a aware UTC
con _ensure_aware_utc() antes de usarlas en queries, resolviendo
el bug donde los tres contadores (total, hoy, semana) devolvían
el mismo valor porque el filtro de fecha se ignoraba.
3. critical_actions_today usa los mismos umbrales que /security/incidents
para que el contador del dashboard coincida con la lista de detalles.
"""
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, or_, desc
@@ -24,31 +49,83 @@ from app.api.v1.audit_helpers import (
detect_mass_deletions, detect_brute_force, detect_privilege_escalation
)
# Instancia del router de FastAPI para este módulo
router = APIRouter()
# Logger estructurado para registrar eventos internos del sistema
logger = structlog.get_logger(__name__)
# =============================================================================
# DEPENDENCIA DE AUTORIZACIÓN
# =============================================================================
def require_auditor_role(current_user: User = Depends(get_current_user)) -> User:
"""Verifica que el usuario tenga rol de auditor"""
"""
Dependencia reutilizable que verifica que el usuario tenga permisos
para ver logs de auditoría.
Solo pueden acceder los roles: ADMIN, SUPPORT_MANAGER, AUDITOR.
Si no tiene el rol correcto, lanza un error 403 Forbidden.
"""
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 a logs de auditoría")
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(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),
resource_type: Optional[str] = Query(None), resource_id: Optional[uuid.UUID] = Query(None),
date_from: Optional[datetime] = Query(None), date_to: Optional[datetime] = Query(None),
search: Optional[str] = Query(None), tenant_id: Optional[uuid.UUID] = Query(None),
all_tenants: bool = Query(False), 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"""
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, "page": page, "all_tenants": all_tenants})
# =============================================================================
# ENDPOINT: LISTA DE LOGS DE AUDITORÍA
# =============================================================================
@router.get("/", response_model=AuditLogListResponse)
async def get_audit_logs(
# Paginación
page: int = Query(default=1, ge=1),
per_page: int = Query(default=50, ge=1, le=100),
# Filtros opcionales
user_id: Optional[uuid.UUID] = Query(None),
action: Optional[str] = Query(None),
resource_type: Optional[str] = Query(None),
resource_id: Optional[uuid.UUID] = Query(None),
date_from: Optional[datetime] = Query(None),
date_to: Optional[datetime] = Query(None),
search: Optional[str] = Query(None),
tenant_id: Optional[uuid.UUID] = Query(None),
all_tenants: bool = Query(False),
# Dependencias de autenticación y base de datos
current_user: User = Depends(require_auditor_role),
current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db)
):
"""
Obtener el historial completo de logs de auditoría con filtros opcionales.
Soporta filtrar por usuario, tipo de acción, recurso afectado, fechas
y búsqueda de texto. También soporta ver logs de todos los tenants
si el usuario tiene permisos de ADMIN o SUPPORT_MANAGER.
"""
logger.info(
"Obteniendo logs de auditoria",
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,
"page": page,
"all_tenants": all_tenants
}
)
# Construir la query base con relación al usuario que hizo la acción
query = select(AuditLog).options(selectinload(AuditLog.user))
# Aplicar filtro de tenant según permisos del usuario
query = apply_tenant_filter(query, current_user, current_tenant, all_tenants, tenant_id)
# Aplicar filtros opcionales uno por uno
if user_id:
query = query.where(AuditLog.user_id == user_id)
if action:
@@ -62,229 +139,609 @@ async def get_audit_logs(page: int = Query(default=1, ge=1), per_page: int = Que
if date_to:
query = query.where(AuditLog.created_at < date_to)
if search:
# Búsqueda parcial en el campo "action" (ej: "ticket" encuentra "ticket.create")
query = query.where(AuditLog.action.ilike(f"%{search}%"))
# Ordenar por fecha descendente (más reciente primero)
query = query.order_by(desc(AuditLog.created_at))
# Contar total de registros para calcular páginas
count_query = select(func.count()).select_from(query.subquery())
total = (await db.execute(count_query)).scalar() or 0
# Aplicar paginación
offset = (page - 1) * per_page
query = query.offset(offset).limit(per_page)
# Ejecutar query y obtener resultados
result = await db.execute(query)
logs = result.scalars().all()
# Calcular número total de páginas
total_pages = (total + per_page - 1) // per_page
# Convertir modelos a schemas de respuesta
logs_response = [AuditLogResponse(**audit_log_to_dict(log)) for log in logs]
return AuditLogListResponse(logs=logs_response, total=total, page=page, per_page=per_page, total_pages=total_pages)
return AuditLogListResponse(
logs=logs_response,
total=total,
page=page,
per_page=per_page,
total_pages=total_pages
)
# =============================================================================
# ENDPOINT: ESTADÍSTICAS DE AUDITORÍA
# =============================================================================
@router.get("/stats", response_model=AuditLogStats)
async def get_audit_stats(all_tenants: bool = Query(False), 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"""
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)
async def get_audit_stats(
all_tenants: bool = Query(False),
current_user: User = Depends(require_auditor_role),
current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db)
):
"""
Obtener estadísticas resumidas de auditoría para el dashboard.
Incluye:
- Total de acciones registradas
- Acciones de las últimas 24 horas
- Acciones de los últimos 7 días
- Incidentes críticos detectados hoy (alineado con /security/incidents)
- Acciones más frecuentes
- Usuarios más activos
- Distribución por tipo de recurso
"""
can_see_all_tenants = current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]
logger.info(
"Obteniendo estadisticas de auditoria",
user_id=str(current_user.id),
tenant_id=str(current_tenant.id),
all_tenants=all_tenants,
can_see_all=can_see_all_tenants
)
# datetime.now(timezone.utc) genera un datetime aware en UTC,
# compatible con la columna TIMESTAMPTZ de PostgreSQL
now = datetime.now(timezone.utc)
# Determinar si se debe filtrar por tenant o ver todos
apply_tenant = not (all_tenants and can_see_all_tenants)
tenant_filter = current_tenant.id if apply_tenant else None
# ------------------------------------------------------------------
# CONTADORES GENERALES
# ------------------------------------------------------------------
# Total histórico de acciones (sin filtro de fecha)
total_actions = await get_count_stat(db, tenant_filter)
# Acciones en las últimas 24 horas
# get_count_stat convierte internamente a aware UTC con _ensure_aware_utc()
actions_today = await get_count_stat(db, tenant_filter, now - timedelta(days=1))
# Acciones en los últimos 7 días
actions_this_week = await get_count_stat(db, tenant_filter, now - timedelta(days=7))
# ------------------------------------------------------------------
# CONTADOR DE INCIDENTES CRÍTICOS
# ------------------------------------------------------------------
# Usa los mismos umbrales que los detectores de /security/incidents
# para que el número del dashboard sea consistente con la lista.
# ------------------------------------------------------------------
today_start = now - timedelta(days=1)
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:
critical_conditions.append(AuditLog.tenant_id == tenant_filter)
critical_actions_today = (await db.execute(select(func.count()).select_from(AuditLog).where(and_(*critical_conditions)))).scalar() or 0
# Contar intentos fallidos de login en las últimas 24 horas
failed_login_count = (await db.execute(
select(func.count()).select_from(AuditLog).where(
AuditLog.action == 'user.login_failed',
AuditLog.created_at >= today_start,
*([AuditLog.tenant_id == tenant_filter] if apply_tenant else [])
)
)).scalar() or 0
# Contar eliminaciones en las últimas 24 horas
deletion_count = (await db.execute(
select(func.count()).select_from(AuditLog).where(
AuditLog.action.like('%.delete'),
AuditLog.created_at >= today_start,
*([AuditLog.tenant_id == tenant_filter] if apply_tenant else [])
)
)).scalar() or 0
# Contar cambios de privilegios en las últimas 24 horas
privilege_count = (await db.execute(
select(func.count()).select_from(AuditLog).where(
AuditLog.action == 'user.update',
AuditLog.created_at >= today_start,
*([AuditLog.tenant_id == tenant_filter] if apply_tenant else [])
)
)).scalar() or 0
# Calcular número real de incidentes usando los mismos umbrales
# que los detectores en /security/incidents:
# - Fuerza bruta: incidente si hay >= 20 intentos fallidos
# - Eliminación masiva: incidente si hay >= 50 eliminaciones
# - Escalada privilegios: incidente si hay >= 3 cambios de rol
critical_actions_today = sum([
1 if failed_login_count >= 20 else 0,
1 if deletion_count >= 50 else 0,
1 if privilege_count >= 3 else 0,
])
# ------------------------------------------------------------------
# DATOS PARA GRÁFICAS Y TABLAS DEL DASHBOARD
# ------------------------------------------------------------------
# Top acciones más frecuentes (ej: "ticket.create", "user.login")
top_actions = await get_top_items(db, AuditLog.action, tenant_filter)
# Distribución por tipo de recurso (ej: "ticket", "user", "tenant")
by_resource_type = await get_top_items(db, AuditLog.resource_type, tenant_filter, limit=10)
# Usuarios más activos (hace join con tabla de usuarios)
top_users = await get_top_items(db, None, tenant_filter, join_user=True)
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)
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
)
# =============================================================================
# ENDPOINT: DETALLE DE UN LOG ESPECÍFICO
# =============================================================================
@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 log de auditoría"""
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 el detalle completo de un log de auditoría por su ID.
Incluye información del usuario que realizó la acción, valores
anteriores y nuevos (para cambios), IP de origen, user agent, etc.
Retorna 404 si el log no existe o no pertenece al tenant del usuario.
"""
# Buscar el log por ID incluyendo los datos del usuario relacionado
query = select(AuditLog).where(AuditLog.id == log_id).options(selectinload(AuditLog.user))
# Aplicar filtro de tenant para garantizar aislamiento multi-tenant
query = apply_tenant_filter(query, current_user, current_tenant)
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} not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Registro de auditoria {log_id} no encontrado"
)
return AuditLogResponse(**audit_log_to_dict(log))
# =============================================================================
# ENDPOINT: ANÁLISIS DE SEGURIDAD EN TIEMPO REAL
# =============================================================================
@router.get("/security/analysis", response_model=SecurityAnalysisResponse)
async def get_security_analysis(all_tenants: bool = Query(False), current_user: User = Depends(require_auditor_role),
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
"""Análisis de seguridad basado en logs de auditoría"""
logger.info("Security analysis requested", user_id=str(current_user.id), tenant_id=str(current_tenant.id))
async def get_security_analysis(
hours: int = Query(default=24, ge=1, le=720),
all_tenants: bool = Query(False),
current_user: User = Depends(require_auditor_role),
current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db)
):
"""
Analizar los logs de auditoría para detectar patrones sospechosos.
Detecta tres tipos de amenazas:
1. Fuerza bruta: Muchos intentos fallidos de login desde las mismas IPs
2. Eliminación masiva: Gran cantidad de registros eliminados en poco tiempo
3. Escalada privilegios: Cambios de roles sospechosos en usuarios
Calcula un nivel de riesgo general (low/medium/high/critical) y
devuelve recomendaciones de acción.
"""
logger.info(
"Analisis de seguridad solicitado",
user_id=str(current_user.id),
tenant_id=str(current_tenant.id),
hours=hours
)
# aware UTC para compatibilidad con TIMESTAMPTZ de PostgreSQL
now = datetime.now(timezone.utc)
analysis_start = now - timedelta(hours=24)
analysis_start = now - timedelta(hours=hours)
query = select(AuditLog).where(AuditLog.created_at >= analysis_start).options(selectinload(AuditLog.user))
query = (
select(AuditLog)
.where(AuditLog.created_at >= analysis_start)
.options(selectinload(AuditLog.user))
)
query = apply_tenant_filter(query, current_user, current_tenant, all_tenants)
result = await db.execute(query)
logs = result.scalars().all()
# ------------------------------------------------------------------
# CONTADORES DE EVENTOS SOSPECHOSOS
# ------------------------------------------------------------------
failed_logins = sum(1 for log in logs if log.action == 'user.login_failed')
mass_deletions = sum(1 for log in logs if '.delete' in log.action)
privilege_changes = sum(1 for log in logs if log.action == 'user.update' and log.new_values and 'role' in log.new_values)
privilege_changes = sum(
1 for log in logs
if log.action == 'user.update'
and log.new_values
and 'role' in log.new_values
)
# ------------------------------------------------------------------
# GENERACIÓN DE PATRONES DE AMENAZA
# ------------------------------------------------------------------
threat_patterns = []
# Amenaza 1: Fuerza bruta (umbral mínimo: 5 intentos fallidos)
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]
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(
id="brute_force_attempt",
type="brute_force",
description=f"Se detectaron {failed_logins} intentos fallidos de login en las últimas 24h",
description=(
f"Se detectaron {failed_logins} intentos fallidos de "
f"login en las ultimas {hours}h"
),
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),
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_ips=list(set(affected_ips_list))[:5],
affected_users=[],
recommended_action="Considerar bloquear IPs con múltiples fallos"
recommended_action="Considerar bloquear IPs con multiples fallos"
))
# Amenaza 2: Eliminación masiva (umbral mínimo: 10 eliminaciones)
if mass_deletions >= 10:
deleting_users = [log.user.email for log in logs if '.delete' in log.action and log.user]
deleting_users = [
log.user.email
for log in logs
if '.delete' in log.action and log.user
]
threat_patterns.append(SecurityThreatPattern(
id="mass_deletion",
type="mass_deletion",
description=f"Se detectaron {mass_deletions} eliminaciones en las últimas 24h",
description=(
f"Se detectaron {mass_deletions} eliminaciones en "
f"las ultimas {hours}h"
),
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),
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_ips=[],
affected_users=list(set(deleting_users))[:5],
recommended_action="Revisar qué usuarios están eliminando recursos"
recommended_action="Revisar que usuarios estan eliminando recursos masivamente"
))
# Amenaza 3: Escalada de privilegios (umbral mínimo: 3 cambios de rol)
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]
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(
id="suspicious_privilege_changes",
type="privilege_escalation",
description=f"Se detectaron {privilege_changes} cambios de privilegios en las últimas 24h",
description=(
f"Se detectaron {privilege_changes} cambios de "
f"privilegios en las ultimas {hours}h"
),
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),
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_ips=[],
affected_users=list(set(affected_users_list))[:5],
recommended_action="Auditar cambios de roles recientes"
))
risk_score = min(100, (failed_logins * 2) + (mass_deletions * 5) + (privilege_changes * 10))
risk_level = "critical" if risk_score >= 80 else "high" if risk_score >= 50 else "medium" if risk_score >= 20 else "low"
# ------------------------------------------------------------------
# CÁLCULO DE NIVEL DE RIESGO GENERAL
# ------------------------------------------------------------------
risk_score = min(
100,
(failed_logins * 2) + (mass_deletions * 5) + (privilege_changes * 10)
)
if risk_score >= 80:
risk_level = "critical"
elif risk_score >= 50:
risk_level = "high"
elif risk_score >= 20:
risk_level = "medium"
else:
risk_level = "low"
# ------------------------------------------------------------------
# RECOMENDACIONES AUTOMÁTICAS
# ------------------------------------------------------------------
recommended_actions = []
if failed_logins >= 20:
recommended_actions.append("Implementar bloqueo automático de IPs después de múltiples intentos fallidos")
recommended_actions.append(
"Implementar bloqueo automatico de IPs despues de multiples intentos fallidos"
)
if mass_deletions >= 50:
recommended_actions.append("Activar confirmación adicional para eliminaciones masivas")
recommended_actions.append(
"Activar confirmacion adicional para eliminaciones masivas"
)
if privilege_changes >= 3:
recommended_actions.append(
"Revisar y aprobar manualmente los cambios de roles recientes"
)
if not recommended_actions:
recommended_actions.append("Continuar monitoreando actividad del sistema")
# Calcular IPs sospechosas (más de 5 intentos fallidos)
suspicious_ips = len(set([log.ip_address for log in logs if log.ip_address and log.action == 'user.login_failed']))
suspicious_ips = len(set(
log.ip_address
for log in logs
if log.ip_address and log.action == 'user.login_failed'
))
# Contar acciones críticas (delete, privilege changes, etc)
critical_actions = mass_deletions + privilege_changes
return SecurityAnalysisResponse(
overall_risk_level=risk_level,
total_threats_detected=len(threat_patterns),
threats=threat_patterns,
analysis_period_hours=24,
generated_at=datetime.utcnow(),
analysis_period_hours=hours,
generated_at=datetime.now(timezone.utc),
failed_login_attempts=failed_logins,
suspicious_ips_count=suspicious_ips,
critical_actions_count=critical_actions,
recommended_actions=recommended_actions
)
# =============================================================================
# ENDPOINT: EJECUTAR ACCIÓN DE SEGURIDAD
# =============================================================================
@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"""
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 una acción de seguridad manual sobre una amenaza detectada.
Acciones disponibles:
- block_ip: Bloquear una dirección IP por X minutos
- notify_admin: Enviar notificación a los administradores
- force_password_reset: Forzar cambio de contraseña a un usuario
- disable_user: Desactivar temporalmente una cuenta de usuario
Solo ADMIN y SUPPORT_MANAGER pueden ejecutar estas acciones.
Todas las acciones quedan registradas en el log de auditoría.
"""
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")
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)
logger.info(
"Accion de seguridad solicitada",
user_id=str(current_user.id),
action_type=action.action_type,
target=action.target
)
# Registrar en auditoría para trazabilidad completa
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 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))
logger.error("Fallo al registrar accion de seguridad en auditoria", error=str(e))
action_messages = {
"block_ip": f"IP {action.target} bloqueada por {action.duration_minutes or 60} minutos. Razón: {action.reason}",
"notify_admin": f"Notificación enviada a administradores sobre: {action.reason}",
"force_password_reset": f"Se forzará cambio de contraseña para {action.target}. Razón: {action.reason}",
"disable_user": f"Usuario {action.target} desactivado temporalmente. Razón: {action.reason}"
"block_ip": (
f"IP {action.target} bloqueada por "
f"{action.duration_minutes or 60} minutos. Razon: {action.reason}"
),
"notify_admin": (
f"Notificacion enviada a administradores sobre: {action.reason}"
),
"force_password_reset": (
f"Se forzara cambio de contrasena para {action.target}. "
f"Razon: {action.reason}"
),
"disable_user": (
f"Usuario {action.target} desactivado temporalmente. "
f"Razon: {action.reason}"
)
}
success = action.action_type in action_messages
message = action_messages.get(action.action_type, f"Tipo de acción no reconocida: {action.action_type}")
message = action_messages.get(
action.action_type,
f"Tipo de accion no reconocida: {action.action_type}"
)
return SecurityActionResponse(success=success, message=message, action_id=None)
@router.get("/security/incidents", response_model=SecurityIncidentListResponse)
async def get_security_incidents(page: int = Query(default=1, ge=1), per_page: int = Query(default=20, ge=1, le=100),
severity: Optional[str] = Query(None), status: Optional[str] = Query(None),
incident_type: Optional[str] = Query(None), search: Optional[str] = Query(None),
all_tenants: bool = Query(False), current_user: User = Depends(require_auditor_role),
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
"""Obtener 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})
# =============================================================================
# ENDPOINT: LISTA DE INCIDENTES DE SEGURIDAD
# =============================================================================
@router.get("/security/incidents", response_model=SecurityIncidentListResponse)
async def get_security_incidents(
# Paginación
page: int = Query(default=1, ge=1),
per_page: int = Query(default=20, ge=1, le=100),
# Filtros opcionales
severity: Optional[str] = Query(None),
status: Optional[str] = Query(None),
incident_type: Optional[str] = Query(None),
search: Optional[str] = Query(None),
all_tenants: bool = Query(False),
# Dependencias
current_user: User = Depends(require_auditor_role),
current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db)
):
"""
Obtener la lista de incidentes de seguridad detectados.
Los incidentes se generan dinámicamente analizando los logs de
auditoría de los últimos 7 días usando tres detectores:
1. detect_brute_force: Analiza intentos fallidos de login
2. detect_mass_deletions: Analiza eliminaciones masivas
3. detect_privilege_escalation: Analiza cambios de rol sospechosos
Los umbrales son los mismos que usa /stats para critical_actions_today,
garantizando consistencia entre el contador y la lista.
"""
logger.info(
"Obteniendo incidentes de seguridad",
user_id=str(current_user.id),
tenant_id=str(current_tenant.id),
filters={
"severity": severity,
"status": status,
"type": incident_type,
"page": page
}
)
# aware UTC para compatibilidad con TIMESTAMPTZ de PostgreSQL
now = datetime.now(timezone.utc)
analysis_start = now - timedelta(days=7)
base_query = select(AuditLog).options(selectinload(AuditLog.user)).where(AuditLog.created_at >= analysis_start)
base_query = (
select(AuditLog)
.options(selectinload(AuditLog.user))
.where(AuditLog.created_at >= analysis_start)
)
base_query = apply_tenant_filter(base_query, current_user, current_tenant, all_tenants)
deletion_result = await db.execute(base_query.where(AuditLog.action.like('%.delete')).order_by(desc(AuditLog.created_at)))
# ------------------------------------------------------------------
# DETECTOR 1: ELIMINACIONES MASIVAS
# ------------------------------------------------------------------
deletion_result = await db.execute(
base_query
.where(AuditLog.action.like('%.delete'))
.order_by(desc(AuditLog.created_at))
)
deletion_logs = deletion_result.scalars().all()
deletion_incidents = detect_mass_deletions(deletion_logs, now)
failed_login_result = await db.execute(base_query.where(AuditLog.action == 'user.login_failed').order_by(desc(AuditLog.created_at)))
# ------------------------------------------------------------------
# DETECTOR 2: FUERZA BRUTA
# ------------------------------------------------------------------
failed_login_result = await db.execute(
base_query
.where(AuditLog.action == 'user.login_failed')
.order_by(desc(AuditLog.created_at))
)
failed_login_logs = failed_login_result.scalars().all()
brute_force_incidents = detect_brute_force(failed_login_logs, now)
privilege_result = await db.execute(base_query.where(and_(AuditLog.action == 'user.update', AuditLog.new_values.op('?')('role'))).order_by(desc(AuditLog.created_at)))
# ------------------------------------------------------------------
# DETECTOR 3: ESCALADA DE PRIVILEGIOS
# El operador '?' verifica si el campo JSON contiene la clave 'role'
# ------------------------------------------------------------------
privilege_result = await db.execute(
base_query
.where(and_(
AuditLog.action == 'user.update',
AuditLog.new_values.op('?')('role')
))
.order_by(desc(AuditLog.created_at))
)
privilege_logs = privilege_result.scalars().all()
privilege_incidents = detect_privilege_escalation(privilege_logs)
incidents = [SecurityIncidentResponse(**inc) for inc in (deletion_incidents + brute_force_incidents + privilege_incidents)]
# Combinar todos los incidentes
incidents = [
SecurityIncidentResponse(**inc)
for inc in (deletion_incidents + brute_force_incidents + privilege_incidents)
]
# ------------------------------------------------------------------
# FILTROS EN MEMORIA (los incidentes son generados dinámicamente)
# ------------------------------------------------------------------
if severity:
incidents = [i for i in incidents if i.severity == severity]
@@ -294,14 +751,29 @@ async def get_security_incidents(page: int = Query(default=1, ge=1), per_page: i
incidents = [i for i in incidents if i.incident_type == incident_type]
if search:
search_lower = search.lower()
incidents = [i for i in incidents if search_lower in i.title.lower() or (i.description and search_lower in i.description.lower())]
incidents = [
i for i in incidents
if search_lower in i.title.lower()
or (i.description and search_lower in i.description.lower())
]
# Ordenar por fecha descendente
incidents.sort(key=lambda x: x.created_at, reverse=True)
# ------------------------------------------------------------------
# PAGINACIÓN MANUAL
# ------------------------------------------------------------------
total = len(incidents)
total_pages = (total + per_page - 1) // per_page
start_idx = (page - 1) * per_page
end_idx = start_idx + per_page
paginated_incidents = incidents[start_idx:end_idx]
return SecurityIncidentListResponse(incidents=paginated_incidents, total=total, page=page, per_page=per_page, total_pages=total_pages)
return SecurityIncidentListResponse(
incidents=paginated_incidents,
total=total,
page=page,
per_page=per_page,
total_pages=total_pages
)

View File

@@ -0,0 +1,761 @@
"""
Reports Endpoints - ServiceManagerWeb
Módulo de reportes y estadísticas del sistema.
Accesible por ADMIN y SUPPORT_MANAGER.
"""
from fastapi import APIRouter, Depends, Query, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, case, text
from typing import Optional, List
from datetime import datetime, timedelta, timezone
import uuid
from app.core.database import get_db
from app.api.deps import get_current_user
from app.models.user import User, UserRole
from app.models.ticket import Ticket, TicketStatus, TicketPriority
from app.models.category import Category
from app.models.system import System
from app.models.tenant import Tenant, TenantStatus
from app.api.schemas.reports import (
ReportSummaryResponse,
TicketsByStatus,
TicketsByPriority,
AgentReportResponse,
AgentReportRow,
CategoryReportResponse,
CategoryReportRow,
ClientReportResponse,
ClientReportRow,
TrendsReportResponse,
TrendDataPoint,
CSATReportResponse,
CSATDistribution,
SystemReportResponse,
SystemReportRow,
)
router = APIRouter()
CLOSED_STATUSES = {TicketStatus.RESOLVED, TicketStatus.CLOSED}
# ===================================
# HELPERS
# ===================================
def require_reports_access(current_user: User = Depends(get_current_user)) -> User:
"""ADMIN, SUPPORT_MANAGER y AUDITOR pueden leer reportes."""
allowed = [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR]
if current_user.role not in allowed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Solo ADMIN, SUPPORT_MANAGER y AUDITOR pueden acceder a los reportes.",
)
return current_user
def require_admin(current_user: User = Depends(get_current_user)) -> User:
"""Solo ADMIN puede ver reportes entre tenants."""
if current_user.role != UserRole.ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Solo ADMIN puede ver reportes de todos los clientes.",
)
return current_user
def _period_dates(days: int) -> tuple[datetime, datetime]:
"""Devuelve (inicio, fin) del período solicitado en UTC."""
end = datetime.now(timezone.utc)
start = end - timedelta(days=days)
return start, end
# ===================================
# 1. RESUMEN GENERAL
# ===================================
@router.get("/summary", response_model=ReportSummaryResponse)
async def get_report_summary(
days: int = Query(default=30, ge=1, le=365, description="Días hacia atrás del período"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_reports_access),
):
"""
Resumen ejecutivo del período seleccionado.
Incluye:
- Total de tickets creados
- Tickets abiertos vs resueltos
- Tiempo promedio de resolución
- Calificación promedio (CSAT)
- Desglose por estado y prioridad
- Comparación con el período anterior
"""
period_start, period_end = _period_dates(days)
prev_start = period_start - timedelta(days=days)
tenant_filter = Ticket.tenant_id == current_user.tenant_id
# ── Conteos por estado ──
status_rows = (await db.execute(
select(Ticket.status, func.count(Ticket.id).label("cnt"))
.where(and_(tenant_filter, Ticket.created_at >= period_start))
.group_by(Ticket.status)
)).all()
by_status = TicketsByStatus()
for row in status_rows:
s = row.status.value if hasattr(row.status, "value") else str(row.status)
setattr(by_status, s.lower(), row.cnt)
by_status.total = sum(
[by_status.new, by_status.triage, by_status.in_progress,
by_status.waiting_customer, by_status.resolved, by_status.closed, by_status.reopened]
)
# ── Conteos por prioridad ──
priority_rows = (await db.execute(
select(Ticket.priority, func.count(Ticket.id).label("cnt"))
.where(and_(tenant_filter, Ticket.created_at >= period_start))
.group_by(Ticket.priority)
)).all()
by_priority = TicketsByPriority()
for row in priority_rows:
p = row.priority.value if hasattr(row.priority, "value") else str(row.priority)
setattr(by_priority, p.lower(), row.cnt)
by_priority.total = sum([by_priority.low, by_priority.medium, by_priority.high, by_priority.urgent])
total_tickets = by_status.total
resolved_tickets = by_status.resolved + by_status.closed
open_tickets = total_tickets - resolved_tickets
# ── Promedio de tiempo de resolución (segundos → horas) ──
res_time_row = (await db.execute(
select(func.avg(
func.extract("epoch", Ticket.resolved_at - Ticket.created_at)
).label("avg_seconds"))
.where(and_(
tenant_filter,
Ticket.created_at >= period_start,
Ticket.resolved_at.isnot(None),
))
)).scalar_one_or_none()
avg_resolution_hours = round(res_time_row / 3600, 2) if res_time_row else None
# ── Promedio de primera respuesta ──
resp_time_row = (await db.execute(
select(func.avg(
func.extract("epoch", Ticket.first_response_at - Ticket.created_at)
).label("avg_seconds"))
.where(and_(
tenant_filter,
Ticket.created_at >= period_start,
Ticket.first_response_at.isnot(None),
))
)).scalar_one_or_none()
avg_first_response_hours = round(resp_time_row / 3600, 2) if resp_time_row else None
# ── CSAT ──
csat_row = (await db.execute(
select(func.avg(Ticket.rating).label("avg"), func.count(Ticket.rating).label("cnt"))
.where(and_(tenant_filter, Ticket.created_at >= period_start, Ticket.rating.isnot(None)))
)).one()
avg_rating = round(float(csat_row.avg), 2) if csat_row.avg else None
total_rated = csat_row.cnt or 0
# ── Comparación con período anterior ──
prev_total = (await db.execute(
select(func.count(Ticket.id))
.where(and_(tenant_filter, Ticket.created_at >= prev_start, Ticket.created_at < period_start))
)).scalar_one_or_none() or 0
prev_resolved = (await db.execute(
select(func.count(Ticket.id))
.where(and_(
tenant_filter,
Ticket.created_at >= prev_start,
Ticket.created_at < period_start,
Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]),
))
)).scalar_one_or_none() or 0
tickets_change_pct = None
if prev_total > 0:
tickets_change_pct = round(((total_tickets - prev_total) / prev_total) * 100, 1)
resolution_change_pct = None
if prev_total > 0 and total_tickets > 0:
cur_rate = resolved_tickets / total_tickets * 100
prev_rate = prev_resolved / prev_total * 100 if prev_total > 0 else 0
resolution_change_pct = round(cur_rate - prev_rate, 1)
return ReportSummaryResponse(
period_start=period_start,
period_end=period_end,
generated_at=datetime.now(timezone.utc),
total_tickets=total_tickets,
open_tickets=open_tickets,
resolved_tickets=resolved_tickets,
avg_resolution_hours=avg_resolution_hours,
avg_first_response_hours=avg_first_response_hours,
avg_rating=avg_rating,
total_rated=total_rated,
by_status=by_status,
by_priority=by_priority,
tickets_change_pct=tickets_change_pct,
resolution_change_pct=resolution_change_pct,
)
# ===================================
# 2. RENDIMIENTO POR AGENTE
# ===================================
@router.get("/by-agent", response_model=AgentReportResponse)
async def get_report_by_agent(
days: int = Query(default=30, ge=1, le=365),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_reports_access),
):
"""
Rendimiento de cada agente en el período:
- Tickets asignados y resueltos
- Tasa de resolución
- Tiempo promedio de resolución
- Calificación promedio (CSAT)
"""
period_start, period_end = _period_dates(days)
tenant_filter = and_(
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_at >= period_start,
Ticket.assigned_to.isnot(None),
)
# Obtener todos los agentes del tenant
agents_result = await db.execute(
select(User).where(
and_(
User.tenant_id == current_user.tenant_id,
User.role.in_([UserRole.AGENT, UserRole.SUPPORT_MANAGER, UserRole.ADMIN]),
User.is_active == True,
)
)
)
agents = agents_result.scalars().all()
rows: List[AgentReportRow] = []
for agent in agents:
agent_filter = and_(tenant_filter, Ticket.assigned_to == agent.id)
total_assigned = (await db.execute(
select(func.count(Ticket.id)).where(agent_filter)
)).scalar_one_or_none() or 0
if total_assigned == 0:
continue # omitir agentes sin tickets en el período
resolved = (await db.execute(
select(func.count(Ticket.id)).where(
and_(agent_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]))
)
)).scalar_one_or_none() or 0
avg_res_seconds = (await db.execute(
select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at)))
.where(and_(agent_filter, Ticket.resolved_at.isnot(None)))
)).scalar_one_or_none()
csat = (await db.execute(
select(func.avg(Ticket.rating), func.count(Ticket.rating))
.where(and_(agent_filter, Ticket.rating.isnot(None)))
)).one()
urgent_handled = (await db.execute(
select(func.count(Ticket.id)).where(
and_(agent_filter, Ticket.priority == TicketPriority.URGENT)
)
)).scalar_one_or_none() or 0
rows.append(AgentReportRow(
agent_id=str(agent.id),
agent_name=f"{agent.first_name} {agent.last_name}",
agent_email=agent.email,
total_assigned=total_assigned,
resolved=resolved,
open=total_assigned - resolved,
resolution_rate=round((resolved / total_assigned * 100), 1) if total_assigned else 0,
avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None,
avg_rating=round(float(csat[0]), 2) if csat[0] else None,
total_rated=csat[1] or 0,
urgent_handled=urgent_handled,
))
rows.sort(key=lambda r: r.resolved, reverse=True)
return AgentReportResponse(
period_start=period_start,
period_end=period_end,
generated_at=datetime.now(timezone.utc),
agents=rows,
total_agents=len(rows),
)
# ===================================
# 3. TICKETS POR CATEGORÍA
# ===================================
@router.get("/by-category", response_model=CategoryReportResponse)
async def get_report_by_category(
days: int = Query(default=30, ge=1, le=365),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_reports_access),
):
"""
Tickets agrupados por categoría con tasa de cumplimiento SLA.
"""
period_start, _ = _period_dates(days)
period_end = datetime.now(timezone.utc)
tenant_filter = and_(
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_at >= period_start,
)
categories_result = await db.execute(
select(Category).where(
and_(Category.tenant_id == current_user.tenant_id, Category.is_active == True)
)
)
categories = categories_result.scalars().all()
rows: List[CategoryReportRow] = []
for cat in categories:
cat_filter = and_(tenant_filter, Ticket.category_id == cat.id)
total = (await db.execute(
select(func.count(Ticket.id)).where(cat_filter)
)).scalar_one_or_none() or 0
if total == 0:
continue
resolved = (await db.execute(
select(func.count(Ticket.id)).where(
and_(cat_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]))
)
)).scalar_one_or_none() or 0
avg_res_seconds = (await db.execute(
select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at)))
.where(and_(cat_filter, Ticket.resolved_at.isnot(None)))
)).scalar_one_or_none()
# SLA compliance: tickets resueltos ANTES del deadline
sla_met = (await db.execute(
select(func.count(Ticket.id)).where(
and_(
cat_filter,
Ticket.resolved_at.isnot(None),
Ticket.sla_resolution_due.isnot(None),
Ticket.resolved_at <= Ticket.sla_resolution_due,
)
)
)).scalar_one_or_none() or 0
tickets_with_sla = (await db.execute(
select(func.count(Ticket.id)).where(
and_(cat_filter, Ticket.sla_resolution_due.isnot(None), Ticket.resolved_at.isnot(None))
)
)).scalar_one_or_none() or 0
sla_compliance_pct = round((sla_met / tickets_with_sla * 100), 1) if tickets_with_sla else 0.0
rows.append(CategoryReportRow(
category_id=str(cat.id),
category_name=cat.name,
total_tickets=total,
open_tickets=total - resolved,
resolved_tickets=resolved,
avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None,
sla_response_hours=cat.sla_response_hours,
sla_resolution_hours=cat.sla_resolution_hours,
sla_compliance_pct=sla_compliance_pct,
))
# Sin categoría
uncategorized = (await db.execute(
select(func.count(Ticket.id)).where(
and_(tenant_filter, Ticket.category_id.is_(None))
)
)).scalar_one_or_none() or 0
rows.sort(key=lambda r: r.total_tickets, reverse=True)
return CategoryReportResponse(
period_start=period_start,
period_end=period_end,
generated_at=datetime.now(timezone.utc),
categories=rows,
uncategorized_count=uncategorized,
)
# ===================================
# 4. TICKETS POR CLIENTE (solo ADMIN)
# ===================================
@router.get("/by-client", response_model=ClientReportResponse)
async def get_report_by_client(
days: int = Query(default=30, ge=1, le=365),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_admin),
):
"""
Tickets agrupados por cliente (tenant). Solo accesible por ADMIN.
Útil para ver qué clientes generan más trabajo.
"""
period_start, period_end = _period_dates(days)
tenants_result = await db.execute(select(Tenant).where(Tenant.status == TenantStatus.ACTIVE))
tenants = tenants_result.scalars().all()
rows: List[ClientReportRow] = []
for tenant in tenants:
t_filter = and_(
Ticket.tenant_id == tenant.id,
Ticket.created_at >= period_start,
)
total = (await db.execute(
select(func.count(Ticket.id)).where(t_filter)
)).scalar_one_or_none() or 0
if total == 0:
continue
resolved = (await db.execute(
select(func.count(Ticket.id)).where(
and_(t_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]))
)
)).scalar_one_or_none() or 0
urgent = (await db.execute(
select(func.count(Ticket.id)).where(
and_(t_filter, Ticket.priority == TicketPriority.URGENT)
)
)).scalar_one_or_none() or 0
csat_row = (await db.execute(
select(func.avg(Ticket.rating))
.where(and_(t_filter, Ticket.rating.isnot(None)))
)).scalar_one_or_none()
avg_res_seconds = (await db.execute(
select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at)))
.where(and_(t_filter, Ticket.resolved_at.isnot(None)))
)).scalar_one_or_none()
last_ticket = (await db.execute(
select(func.max(Ticket.created_at)).where(t_filter)
)).scalar_one_or_none()
rows.append(ClientReportRow(
tenant_id=str(tenant.id),
tenant_name=tenant.name,
total_tickets=total,
open_tickets=total - resolved,
resolved_tickets=resolved,
urgent_tickets=urgent,
avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None,
avg_rating=round(float(csat_row), 2) if csat_row else None,
last_ticket_at=last_ticket,
))
rows.sort(key=lambda r: r.total_tickets, reverse=True)
return ClientReportResponse(
period_start=period_start,
period_end=period_end,
generated_at=datetime.now(timezone.utc),
clients=rows,
total_clients=len(rows),
)
# ===================================
# 5. TENDENCIAS (TICKETS EN EL TIEMPO)
# ===================================
@router.get("/trends", response_model=TrendsReportResponse)
async def get_report_trends(
days: int = Query(default=30, ge=7, le=90, description="Número de días (7-90)"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_reports_access),
):
"""
Evolución diaria de tickets creados y resueltos.
Útil para detectar picos de trabajo.
"""
period_start, period_end = _period_dates(days)
tenant_filter = Ticket.tenant_id == current_user.tenant_id
# Tickets creados por día
created_rows = (await db.execute(
select(
func.date_trunc("day", Ticket.created_at).label("day"),
func.count(Ticket.id).label("cnt"),
)
.where(and_(tenant_filter, Ticket.created_at >= period_start))
.group_by(func.date_trunc("day", Ticket.created_at))
.order_by(func.date_trunc("day", Ticket.created_at))
)).all()
# Tickets resueltos por día (según resolved_at)
resolved_rows = (await db.execute(
select(
func.date_trunc("day", Ticket.resolved_at).label("day"),
func.count(Ticket.id).label("cnt"),
)
.where(and_(
tenant_filter,
Ticket.resolved_at >= period_start,
Ticket.resolved_at.isnot(None),
))
.group_by(func.date_trunc("day", Ticket.resolved_at))
.order_by(func.date_trunc("day", Ticket.resolved_at))
)).all()
created_map: dict[str, int] = {r.day.strftime("%Y-%m-%d"): r.cnt for r in created_rows}
resolved_map: dict[str, int] = {r.day.strftime("%Y-%m-%d"): r.cnt for r in resolved_rows}
# Un punto por cada día del período
data_points: List[TrendDataPoint] = []
current = period_start
while current <= period_end:
date_str = current.strftime("%Y-%m-%d")
c = created_map.get(date_str, 0)
r = resolved_map.get(date_str, 0)
data_points.append(TrendDataPoint(date=date_str, created=c, resolved=r, net_open=c - r))
current += timedelta(days=1)
return TrendsReportResponse(
period_start=period_start,
period_end=period_end,
generated_at=datetime.now(timezone.utc),
data_points=data_points,
total_days=len(data_points),
)
# ===================================
# 6. SATISFACCIÓN DEL CLIENTE (CSAT)
# ===================================
@router.get("/csat", response_model=CSATReportResponse)
async def get_report_csat(
days: int = Query(default=30, ge=1, le=365),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_reports_access),
):
"""
Reporte de satisfacción del cliente (calificaciones 1-5).
Incluye distribución, promedio por categoría y por agente.
"""
period_start, period_end = _period_dates(days)
tenant_filter = and_(
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_at >= period_start,
)
# Total y promedio general
general = (await db.execute(
select(func.avg(Ticket.rating).label("avg"), func.count(Ticket.rating).label("rated"))
.where(and_(tenant_filter, Ticket.rating.isnot(None)))
)).one()
total_tickets = (await db.execute(
select(func.count(Ticket.id)).where(tenant_filter)
)).scalar_one_or_none() or 0
# Distribución por estrellas
dist_rows = (await db.execute(
select(Ticket.rating, func.count(Ticket.id).label("cnt"))
.where(and_(tenant_filter, Ticket.rating.isnot(None)))
.group_by(Ticket.rating)
)).all()
dist = CSATDistribution()
for row in dist_rows:
setattr(dist, f"rating_{row.rating}", row.cnt)
# Promedio por categoría
cat_rows = (await db.execute(
select(
Category.name.label("cat_name"),
func.avg(Ticket.rating).label("avg"),
func.count(Ticket.rating).label("cnt"),
)
.join(Category, Ticket.category_id == Category.id, isouter=True)
.where(and_(tenant_filter, Ticket.rating.isnot(None)))
.group_by(Category.name)
.order_by(func.avg(Ticket.rating).desc())
)).all()
by_category = [
{
"category": row.cat_name or "Sin categoría",
"avg_rating": round(float(row.avg), 2) if row.avg else None,
"total_rated": row.cnt,
}
for row in cat_rows
]
# Promedio por agente
agent_rows = (await db.execute(
select(
User.first_name.label("fname"),
User.last_name.label("lname"),
func.avg(Ticket.rating).label("avg"),
func.count(Ticket.rating).label("cnt"),
)
.join(User, Ticket.assigned_to == User.id, isouter=True)
.where(and_(tenant_filter, Ticket.rating.isnot(None)))
.group_by(User.first_name, User.last_name)
.order_by(func.avg(Ticket.rating).desc())
)).all()
by_agent = [
{
"agent": f"{row.fname or ''} {row.lname or ''}".strip() or "Sin asignar",
"avg_rating": round(float(row.avg), 2) if row.avg else None,
"total_rated": row.cnt,
}
for row in agent_rows
]
# Últimos comentarios de calificación (rating_comment)
comment_rows = (await db.execute(
select(Ticket.rating, Ticket.rating_comment, Ticket.rated_at)
.where(and_(
tenant_filter,
Ticket.rating.isnot(None),
Ticket.rating_comment.isnot(None),
Ticket.rating_comment != "",
))
.order_by(Ticket.rated_at.desc())
.limit(10)
)).all()
recent_comments = [
{
"rating": row.rating,
"comment": row.rating_comment,
"rated_at": row.rated_at.isoformat() if row.rated_at else None,
}
for row in comment_rows
]
total_rated = general.rated or 0
response_rate = round((total_rated / total_tickets * 100), 1) if total_tickets else 0.0
return CSATReportResponse(
period_start=period_start,
period_end=period_end,
generated_at=datetime.now(timezone.utc),
avg_rating=round(float(general.avg), 2) if general.avg else None,
total_rated=total_rated,
total_tickets=total_tickets,
response_rate=response_rate,
distribution=dist,
by_category=by_category,
by_agent=by_agent,
recent_comments=recent_comments,
)
# ===================================
# 7. TICKETS POR SISTEMA AFECTADO
# ===================================
@router.get("/by-system", response_model=SystemReportResponse)
async def get_report_by_system(
days: int = Query(default=30, ge=1, le=365),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_reports_access),
):
"""
Tickets agrupados por sistema afectado.
Útil para detectar qué sistemas generan más incidentes.
"""
period_start, period_end = _period_dates(days)
tenant_filter = and_(
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_at >= period_start,
)
systems_result = await db.execute(
select(System).where(
and_(System.tenant_id == current_user.tenant_id, System.is_active == True)
)
)
systems = systems_result.scalars().all()
rows: List[SystemReportRow] = []
for sys in systems:
sys_filter = and_(tenant_filter, Ticket.affected_system_id == sys.id)
total = (await db.execute(
select(func.count(Ticket.id)).where(sys_filter)
)).scalar_one_or_none() or 0
if total == 0:
continue
resolved = (await db.execute(
select(func.count(Ticket.id)).where(
and_(sys_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]))
)
)).scalar_one_or_none() or 0
urgent = (await db.execute(
select(func.count(Ticket.id)).where(
and_(sys_filter, Ticket.priority == TicketPriority.URGENT)
)
)).scalar_one_or_none() or 0
avg_res_seconds = (await db.execute(
select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at)))
.where(and_(sys_filter, Ticket.resolved_at.isnot(None)))
)).scalar_one_or_none()
rows.append(SystemReportRow(
system_id=str(sys.id),
system_name=sys.name,
total_tickets=total,
open_tickets=total - resolved,
resolved_tickets=resolved,
urgent_tickets=urgent,
avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None,
))
# Sin sistema asignado
no_system = (await db.execute(
select(func.count(Ticket.id)).where(
and_(tenant_filter, Ticket.affected_system_id.is_(None))
)
)).scalar_one_or_none() or 0
rows.sort(key=lambda r: r.total_tickets, reverse=True)
return SystemReportResponse(
period_start=period_start,
period_end=period_end,
generated_at=datetime.now(timezone.utc),
systems=rows,
no_system_count=no_system,
)

View File

@@ -6,7 +6,7 @@ Router principal para la API v1
from fastapi import APIRouter
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports
api_router = APIRouter()
@@ -74,3 +74,10 @@ api_router.include_router(
prefix="/sla",
tags=["sla"]
)
# Reports routes
api_router.include_router(
reports.router,
prefix="/reports",
tags=["reports"]
)

View File

@@ -1,7 +1,7 @@
"""
Audit Log Model - ServiceManagerWeb
Modelo para bitácora de auditoría y compliance.
Modelo para bitácora de auditoría y compliance.
Registra todas las acciones importantes del sistema.
"""
@@ -10,7 +10,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import INET, JSONB
from typing import Optional, Dict, Any, TYPE_CHECKING
import uuid
from datetime import datetime
from datetime import datetime, timezone
from app.core.database import Base, GUID
@@ -21,20 +21,20 @@ if TYPE_CHECKING:
class AuditLog(Base):
"""
Bitácora de auditoría para tracking completo de acciones.
Bitácora de auditoría para tracking completo de acciones.
Registra:
- Qui├®n hizo la acci├│n (user_id)
- Qu├® hizo (action)
- Sobre qu├® recurso (resource_type + resource_id)
- Cuándo lo hizo (created_at)
- Desde d├│nde (ip_address, user_agent)
- Qu├® cambi├│ (old_values, new_values)
- Quién hizo la acción (user_id)
- Qué hizo (action)
- Sobre qué recurso (resource_type + resource_id)
- Cuándo lo hizo (created_at)
- Desde dónde (ip_address, user_agent)
- Qué cambió (old_values, new_values)
"""
__tablename__ = "audit_logs"
# Multi-tenancy
# Multi-tenancy: cada registro pertenece a un tenant específico
tenant_id: Mapped[uuid.UUID] = mapped_column(
GUID(),
ForeignKey("tenants.id", ondelete="CASCADE"),
@@ -42,7 +42,7 @@ class AuditLog(Base):
index=True
)
# Usuario que ejecut├│ la acci├│n (NULL = acci├│n del sistema)
# Usuario que ejecutó la acción (NULL = acción del sistema)
user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
GUID(),
ForeignKey("users.id", ondelete="SET NULL"),
@@ -50,7 +50,8 @@ class AuditLog(Base):
index=True
)
# Acci├│n realizada (ej: "user.login", "ticket.create", "ticket.assign")
# Acción realizada en formato "recurso.verbo"
# Ejemplos: "user.login", "ticket.create", "ticket.assign"
action: Mapped[str] = mapped_column(
String(100),
nullable=False,
@@ -70,82 +71,104 @@ class AuditLog(Base):
nullable=True
)
# Contexto de la request
# Contexto de la request: IP y navegador del usuario
ip_address: Mapped[Optional[str]] = mapped_column(
String(45).with_variant(INET, "postgresql"),
nullable=True,
)
user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Correlation ID para rastrear requests relacionadas
# Correlation ID para rastrear todas las requests relacionadas
# en una misma operación o sesión
correlation_id: Mapped[Optional[uuid.UUID]] = mapped_column(
GUID(),
nullable=True,
index=True
)
# Valores antes del cambio (JSON)
# Estado del recurso antes del cambio (para auditoría de cambios)
old_values: Mapped[Optional[Dict[str, Any]]] = mapped_column(
JSON().with_variant(JSONB, "postgresql"),
nullable=True
)
# Valores despu├®s del cambio (JSON)
# Estado del recurso después del cambio (para auditoría de cambios)
new_values: Mapped[Optional[Dict[str, Any]]] = mapped_column(
JSON().with_variant(JSONB, "postgresql"),
nullable=True
)
# Metadata adicional (cualquier info relevante)
# Nota: 'metadata' está reservado en SQLAlchemy, usamos 'extra_metadata'
# Metadata adicional con cualquier información relevante del contexto
# Nota: 'metadata' está reservado en SQLAlchemy, se usa 'extra_metadata'
# como nombre del atributo Python, pero la columna en BD se llama 'metadata'
extra_metadata: Mapped[Optional[Dict[str, Any]]] = mapped_column(
'metadata', # Nombre real de la columna en BD
'metadata',
JSON().with_variant(JSONB, "postgresql"),
nullable=True
)
# Timestamp
# Timestamp de creación con timezone
# CORRECCIÓN: default=lambda: datetime.now(timezone.utc) genera un
# datetime aware en UTC, compatible con DateTime(timezone=True).
# El default anterior (datetime.utcnow) generaba datetimes naive,
# causando que los filtros de fecha fallaran silenciosamente porque
# SQLAlchemy no podía comparar aware vs naive correctamente.
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=datetime.utcnow,
default=lambda: datetime.now(timezone.utc),
nullable=False,
index=True
)
# Relaciones
# Relaciones con otros modelos
tenant: Mapped["Tenant"] = relationship("Tenant", foreign_keys=[tenant_id])
user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[user_id])
# Índices compuestos para queries comunes
# Índices compuestos para optimizar las queries más frecuentes
__table_args__ = (
# Filtrar logs por tenant y tipo de acción (uso más común)
Index('idx_audit_logs_tenant_action', 'tenant_id', 'action'),
# Buscar el historial de un recurso específico
Index('idx_audit_logs_resource', 'resource_type', 'resource_id'),
# Ver la actividad de un usuario ordenada por fecha
Index('idx_audit_logs_user_created', 'user_id', 'created_at'),
)
# Configuraci├│n del mapper: excluir updated_at porque audit logs son inmutables
# Los audit logs son inmutables: nunca se actualizan, solo se crean
# Por eso se excluye updated_at del mapper
__mapper_args__ = {
"exclude_properties": ["updated_at"]
}
def __repr__(self) -> str:
return f"<AuditLog(action='{self.action}', resource='{self.resource_type}:{self.resource_id}')>"
return (
f"<AuditLog("
f"action='{self.action}', "
f"resource='{self.resource_type}:{self.resource_id}'"
f")>"
)
@property
def action_display(self) -> str:
"""Formato amigable de la acci├│n."""
"""
Formato legible de la acción para mostrar en la interfaz.
Convierte el formato interno "recurso.verbo" a texto descriptivo.
Ejemplo: "ticket.create""creó ticket"
"""
parts = self.action.split('.')
if len(parts) == 2:
resource, verb = parts
verb_map = {
'create': 'cre├│',
'update': 'actualiz├│',
'delete': 'elimin├│',
'login': 'inici├│ sesi├│n',
'logout': 'cerr├│ sesi├│n',
'assign': 'asign├│',
'close': 'cerr├│',
'reopen': 'reabri├│'
'create': 'creó',
'update': 'actualizó',
'delete': 'eliminó',
'login': 'inició sesión',
'logout': 'cerró sesión',
'login_failed': 'intentó iniciar sesión',
'assign': 'asignó',
'close': 'cerró',
'reopen': 'reabrió'
}
return f"{verb_map.get(verb, verb)} {resource}"
return self.action

View File

@@ -0,0 +1,49 @@
"""
Script para resetear contraseñas de todos los usuarios a valores conocidos.
Ejecutar con: python -m scripts.reset_passwords (desde /app en el contenedor)
"""
import asyncio
from sqlalchemy import select, update
from app.core.database import AsyncSessionLocal
from app.core.security import security
from app.models.user import User
# Mapa email -> nueva contraseña
PASSWORD_MAP = {
"admin@aduanasoft.com": "admin123",
"admin@test.com": "admin123",
"manager@aduanasoft.com": "manager123",
"agente@aduanasoft.com": "agente123",
"auditor1@test.com": "auditor123",
"admin-cliente@empresa-demo.com": "clienteadmin123",
"cliente@empresa-demo.com": "cliente123",
"test_user@aduanasoft.com": "test123",
}
async def reset_all_passwords():
async with AsyncSessionLocal() as db:
result = await db.execute(select(User))
users = result.scalars().all()
updated = 0
skipped = 0
for user in users:
if user.email in PASSWORD_MAP:
plain = PASSWORD_MAP[user.email]
user.password_hash = security.hash_password(plain)
user.email_verified = True
user.is_active = True
updated += 1
print(f"{user.email}{plain}")
else:
skipped += 1
print(f" ⚠️ {user.email} (sin contraseña definida, se omite)")
await db.commit()
print(f"\nResumen: {updated} actualizados, {skipped} omitidos")
print("\n📋 Credenciales listas:")
for email, pwd in PASSWORD_MAP.items():
print(f" {email} / {pwd}")
if __name__ == "__main__":
asyncio.run(reset_all_passwords())

View File

@@ -415,18 +415,19 @@ INSERT INTO tenants (name, slug, contact_email) VALUES
('Aduanasoft Demo', 'aduanasoft-demo', 'demo@aduanasoft.com');
-- Usuario admin por defecto (password: admin123)
-- Hash generado con Argon2: $argon2id$v=19$m=65536,t=3,p=4$...
-- Hash Argon2id generado con m=65536,t=3,p=4
INSERT INTO users (tenant_id, email, first_name, last_name, password_hash, role, is_active, email_verified)
SELECT
id,
'admin@aduanasoft.com',
'Admin',
'Sistema',
'$argon2id$v=19$m=65536,t=3,p=4$example_hash_here',
'$argon2id$v=19$m=65536,t=3,p=4$wpjz/t+bM4bQmtM6B6A0pg$ELwnGUL4S1Y6tywp0LS6cre0bvWEoVuJ845spZ9Z9IQ',
'ADMIN',
true,
true
FROM tenants WHERE slug = 'aduanasoft-demo';
FROM tenants WHERE slug = 'aduanasoft-demo'
ON CONFLICT (tenant_id, email) DO NOTHING;
-- Categorías por defecto
INSERT INTO ticket_categories (tenant_id, name, description, sla_response_hours, sla_resolution_hours)

View File

@@ -164,6 +164,8 @@ services:
- NODE_ENV=${ENVIRONMENT:-development}
- PUBLIC_API_URL=http://backend:8000
- PUBLIC_APP_NAME=ServiceManager Cliente
- PORT=3000
- HMR_CLIENT_PORT=3000
volumes:
- ./frontend-client:/app
- /app/node_modules
@@ -189,6 +191,8 @@ services:
- NODE_ENV=${ENVIRONMENT:-development}
- PUBLIC_API_URL=http://backend:8000
- PUBLIC_APP_NAME=ServiceManager Admin
- PORT=3000
- HMR_CLIENT_PORT=3001
volumes:
- ./frontend-internal:/app
- /app/node_modules

View File

@@ -4,14 +4,26 @@
import Toast from '$lib/components/Toast.svelte';
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import '../app.css';
let mounted = false;
onMount(() => {
auth.init();
mounted = true;
});
$: showHeader = !$page.url.pathname.startsWith('/login') && !$page.url.pathname.startsWith('/register');
// Guard reactivo global: redirige a /login si no está autenticado en rutas protegidas
const publicRoutes = ['/login', '/register', '/forgot-password', '/reset-password'];
$: if (browser && mounted && !$auth.isAuthenticated &&
!publicRoutes.some(r => $page.url.pathname.startsWith(r))) {
goto('/login');
}
$: showHeader = !publicRoutes.some(r => $page.url.pathname.startsWith(r));
</script>
<div class="min-h-screen bg-gray-50 font-sans">

View File

@@ -216,12 +216,13 @@
>Recordar en este equipo</label
>
</div>
<a
href="/forgot-password"
class="text-sm font-medium text-blue-600 hover:text-blue-500"
<button
type="button"
class="text-sm font-medium text-blue-600 hover:text-blue-500 bg-transparent border-none p-0 cursor-pointer"
on:click={() => goto('/forgot-password')}
>
Olvide mi clave
</a>
</button>
</div>
</div>
{:else}

View File

@@ -10,6 +10,16 @@ export default defineConfig({
usePolling: true,
interval: 500
},
// HMR: el browser llega al contenedor en el mismo puerto 3000
hmr: {
host: 'localhost',
clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3000')
},
// Permitir que Vite sirva archivos del filesystem del contenedor
fs: {
allow: ['/app', '.'],
strict: false
},
proxy: {
'/api': {
target: process.env.PUBLIC_API_URL || 'http://localhost:8000',

View File

@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev --port 3000 --host 0.0.0.0",
"dev": "vite dev --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview --port 3000 --host 0.0.0.0",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",

View File

@@ -46,7 +46,7 @@
);
}
if (role === 'ADMIN') {
if (role === 'ADMIN' || role === 'SUPPORT_MANAGER') {
baseNavigation.push(
{
name: 'SLA Management',
@@ -57,7 +57,12 @@
name: 'Reportes',
href: '/reports',
icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z'
},
}
);
}
if (role === 'ADMIN') {
baseNavigation.push(
{
name: 'Auditoría',
href: '/audit',
@@ -67,6 +72,11 @@
name: 'Seguridad',
href: '/audit/security',
icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z'
},
{
name: 'Reporte Endpoints',
href: '/test-report',
icon: 'M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2V9M9 21H5a2 2 0 01-2-2V9m0 0h18'
}
);
}

View File

@@ -0,0 +1,161 @@
import { writable } from 'svelte/store';
/** All available dashboard modules */
export interface DashboardModule {
id: string;
title: string;
description: string;
icon: string;
href: string;
color: string;
/** Minimum role required to see this module */
roles: string[];
}
export const ALL_MODULES: DashboardModule[] = [
{
id: 'tenants',
title: 'Clientes',
description: 'Gestión de organizaciones y tenants',
icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4',
href: '/tenants',
color: 'bg-blue-600',
roles: ['ADMIN', 'SUPPORT_MANAGER']
},
{
id: 'users',
title: 'Usuarios',
description: 'Administración de usuarios y roles',
icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z',
href: '/users',
color: 'bg-green-600',
roles: ['ADMIN', 'SUPPORT_MANAGER']
},
{
id: 'tickets',
title: 'Tickets',
description: 'Gestión y seguimiento de tickets de soporte',
icon: 'M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2',
href: '/tickets',
color: 'bg-indigo-600',
roles: ['ADMIN', 'SUPPORT_MANAGER', 'AGENT']
},
{
id: 'systems',
title: 'Sistemas',
description: 'Catálogo de sistemas soportados',
icon: 'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01',
href: '/systems',
color: 'bg-gray-700',
roles: ['ADMIN', 'SUPPORT_MANAGER']
},
{
id: 'categories',
title: 'Categorías',
description: 'Clasificación de tickets por área',
icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10',
href: '/categories',
color: 'bg-orange-600',
roles: ['ADMIN', 'SUPPORT_MANAGER']
},
{
id: 'sla',
title: 'SLA Management',
description: 'Monitoreo de tiempos de respuesta y SLAs',
icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
href: '/sla',
color: 'bg-teal-600',
roles: ['ADMIN', 'SUPPORT_MANAGER']
},
{
id: 'reports',
title: 'Reportes',
description: 'Informes estadísticos y análisis de rendimiento',
icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
href: '/reports',
color: 'bg-purple-600',
roles: ['ADMIN', 'SUPPORT_MANAGER']
},
{
id: 'audit',
title: 'Auditoría',
description: 'Bitácora de acciones y trazabilidad del sistema',
icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
href: '/audit',
color: 'bg-red-700',
roles: ['ADMIN', 'AUDITOR']
},
{
id: 'security',
title: 'Seguridad',
description: 'Análisis de amenazas y eventos de seguridad',
icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z',
href: '/audit/security',
color: 'bg-yellow-600',
roles: ['ADMIN']
},
{
id: 'endpoints',
title: 'Reporte de Endpoints',
description: 'Estado y diagnóstico de todos los endpoints API',
icon: 'M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2V9M9 21H5a2 2 0 01-2-2V9m0 0h18',
href: '/test-report',
color: 'bg-cyan-600',
roles: ['ADMIN']
}
];
const STORAGE_KEY = 'dashboard_module_visibility';
function getInitialVisibility(): Record<string, boolean> {
if (typeof window === 'undefined') {
return Object.fromEntries(ALL_MODULES.map(m => [m.id, true]));
}
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) return JSON.parse(stored);
} catch { /* ignore */ }
return Object.fromEntries(ALL_MODULES.map(m => [m.id, true]));
}
function createDashboardConfig() {
const { subscribe, set, update } = writable<Record<string, boolean>>(getInitialVisibility());
return {
subscribe,
toggle(id: string) {
update(state => {
const next = { ...state, [id]: !state[id] };
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
}
return next;
});
},
setVisible(id: string, visible: boolean) {
update(state => {
const next = { ...state, [id]: visible };
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
}
return next;
});
},
showAll() {
const all = Object.fromEntries(ALL_MODULES.map(m => [m.id, true]));
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
}
set(all);
},
reset() {
const defaults = Object.fromEntries(ALL_MODULES.map(m => [m.id, true]));
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, JSON.stringify(defaults));
}
set(defaults);
}
};
}
export const dashboardConfig = createDashboardConfig();

View File

@@ -5,14 +5,24 @@
import { toast } from '$lib/stores/toast.js';
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import '../app.css';
let sidebarOpen = false;
let mounted = false;
onMount(() => {
auth.init();
mounted = true;
});
// Guard reactivo global: redirige a /login si no está autenticado
$: if (browser && mounted && !$auth.isAuthenticated && $page.url.pathname !== '/login') {
goto('/login');
}
function toggleSidebar() {
sidebarOpen = !sidebarOpen;
}

View File

@@ -2,7 +2,9 @@
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import { goto } from '$app/navigation';
import Icon from '$lib/components/Icon.svelte';
import { dashboardConfig, ALL_MODULES, type DashboardModule } from '$lib/stores/dashboardConfig.js';
let showSettings = false;
onMount(() => {
if (!$auth.isAuthenticated) {
@@ -10,36 +12,17 @@
}
});
const cards = [
{
title: 'Clientes',
description: 'Gestión de organizaciones y tenants',
icon: 'users',
href: '/tenants',
color: 'bg-blue-600'
},
{
title: 'Usuarios',
description: 'Administración de usuarios y roles',
icon: 'user-plus',
href: '/users',
color: 'bg-green-600'
},
{
title: 'Sistemas',
description: 'Catálogo de sistemas soportados',
icon: 'server',
href: '/systems',
color: 'bg-gray-700'
},
{
title: 'Categorías',
description: 'Clasificación de tickets',
icon: 'tag',
href: '/categories',
color: 'bg-orange-600'
}
];
const role = $auth.user?.role ?? '';
/** Only modules the current role can access */
$: accessibleModules = ALL_MODULES.filter(m => m.roles.includes(role) || role === 'ADMIN');
/** Modules that are visible (enabled by user + accessible by role) */
$: visibleModules = accessibleModules.filter(m => $dashboardConfig[m.id] !== false);
function toggleSettings() {
showSettings = !showSettings;
}
</script>
<svelte:head>
@@ -47,40 +30,106 @@
</svelte:head>
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
<!-- Header -->
<div class="md:flex md:items-center md:justify-between">
<div class="flex-1 min-w-0">
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:text-3xl sm:truncate">
Panel de Administración
</h2>
<p class="mt-1 text-sm text-gray-500">
Bienvenido al sistema de gestión interna.
Bienvenido al sistema de gestión interna.
</p>
</div>
<div class="mt-4 flex md:mt-0 md:ml-4 gap-2">
<button
on:click={toggleSettings}
class="inline-flex items-center gap-1.5 px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Configurar
</button>
</div>
</div>
<div class="mt-8 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
{#each cards as card}
<a href={card.href} class="bg-white overflow-hidden shadow rounded-lg hover:shadow-md transition-shadow duration-200 cursor-pointer group">
<div class="p-5">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
{card.title}
</dt>
<dd>
<div class="text-xs text-gray-900 font-light mt-1">
{card.description}
</div>
</dd>
</dl>
<!-- Settings Panel -->
{#if showSettings}
<div class="mt-6 bg-white border border-gray-200 rounded-lg shadow-sm p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold text-gray-900">Módulos visibles en el dashboard</h3>
<div class="flex gap-2">
<button
on:click={() => dashboardConfig.showAll()}
class="text-xs text-blue-600 hover:text-blue-800 underline"
>
Mostrar todos
</button>
</div>
<div class="bg-gray-50 px-5 py-3">
<div class="text-sm">
<span class="font-medium text-blue-700 hover:text-blue-900">
Ver detalles
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{#each accessibleModules as mod}
<label class="flex items-center gap-2 p-3 border rounded-lg cursor-pointer hover:bg-gray-50 {$dashboardConfig[mod.id] !== false ? 'border-blue-300 bg-blue-50' : 'border-gray-200'}">
<input
type="checkbox"
checked={$dashboardConfig[mod.id] !== false}
on:change={() => dashboardConfig.toggle(mod.id)}
class="rounded text-blue-600 focus:ring-blue-500"
/>
<div class="min-w-0">
<div class="flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full {mod.color} flex-shrink-0"></span>
<span class="text-sm font-medium text-gray-800 truncate">{mod.title}</span>
</div>
</div>
</label>
{/each}
</div>
<p class="mt-3 text-xs text-gray-400">Las preferencias se guardan automáticamente en este navegador.</p>
</div>
{/if}
<!-- Module Cards -->
{#if visibleModules.length === 0}
<div class="mt-10 text-center py-16 bg-white rounded-lg border-2 border-dashed border-gray-200">
<svg class="mx-auto h-10 w-10 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
<p class="mt-3 text-sm text-gray-500">No hay módulos visibles.</p>
<button on:click={() => dashboardConfig.showAll()} class="mt-3 text-sm text-blue-600 hover:underline">
Restaurar todos los módulos
</button>
</div>
{:else}
<div class="mt-8 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each visibleModules as mod}
<a
href={mod.href}
class="bg-white overflow-hidden shadow rounded-lg hover:shadow-md transition-all duration-200 cursor-pointer group flex flex-col"
>
<div class="p-5 flex-1">
<div class="flex items-center gap-3 mb-2">
<div class="w-9 h-9 rounded-lg {mod.color} flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={mod.icon} />
</svg>
</div>
<span class="text-sm font-semibold text-gray-800 group-hover:text-blue-700 transition-colors">
{mod.title}
</span>
</div>
<p class="text-xs text-gray-500 leading-relaxed">{mod.description}</p>
</div>
<div class="bg-gray-50 px-5 py-2.5 border-t border-gray-100">
<span class="text-xs font-medium text-blue-600 group-hover:text-blue-800 transition-colors">
Abrir módulo →
</span>
</div>
</div>
</a>
{/each}
</div>
</a>
{/each}
</div>
{/if}
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,661 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/utils/api';
import { toast } from '$lib/stores/toast';
import { auth } from '$lib/stores/auth';
import { get } from 'svelte/store';
let isLoading = false;
let days = 30;
let activeTab = 'summary';
const user = get(auth).user;
const isAdmin = user?.role === 'ADMIN';
let summary: any = null;
let agentReport: any = null;
let catReport: any = null;
let sysReport: any = null;
let clientReport: any = null;
let trendsReport: any = null;
let csatReport: any = null;
const tabs = [
{ id: 'summary', label: 'Resumen', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' },
{ id: 'agents', label: 'Por Agente', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197' },
{ id: 'categories', label: 'Por Categoría', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' },
{ id: 'systems', label: 'Por Sistema', icon: 'M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2V9M9 21H5a2 2 0 01-2-2V9m0 0h18' },
{ id: 'clients', label: 'Por Cliente', icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4', adminOnly: true },
{ id: 'trends', label: 'Tendencias', icon: 'M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z' },
{ id: 'csat', label: 'Satisfacción', icon: 'M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z' },
].filter(t => !t.adminOnly || isAdmin);
async function loadTab(tab: string) {
isLoading = true;
try {
switch (tab) {
case 'summary': summary = await api.get(`/reports/summary?days=${days}`); break;
case 'agents': agentReport = await api.get(`/reports/by-agent?days=${days}`); break;
case 'categories': catReport = await api.get(`/reports/by-category?days=${days}`); break;
case 'systems': sysReport = await api.get(`/reports/by-system?days=${days}`); break;
case 'clients': if (isAdmin) clientReport = await api.get(`/reports/by-client?days=${days}`); break;
case 'trends': trendsReport = await api.get(`/reports/trends?days=${Math.min(days, 90)}`); break;
case 'csat': csatReport = await api.get(`/reports/csat?days=${days}`); break;
}
} catch (e: any) {
toast.error('Error cargando reporte: ' + (e.message ?? 'Error desconocido'));
} finally {
isLoading = false;
}
}
async function switchTab(tab: string) { activeTab = tab; await loadTab(tab); }
async function reloadAll() { await loadTab(activeTab); }
onMount(() => loadTab('summary'));
const STATUS_MAP: Record<string, { label: string; color: string }> = {
NEW: { label: 'Nuevo', color: 'blue' },
TRIAGE: { label: 'Triaje', color: 'purple' },
IN_PROGRESS: { label: 'En progreso', color: 'indigo' },
WAITING_CUSTOMER: { label: 'Esp. cliente', color: 'yellow' },
RESOLVED: { label: 'Resuelto', color: 'green' },
CLOSED: { label: 'Cerrado', color: 'gray' },
REOPENED: { label: 'Reabierto', color: 'red' },
};
const PRIORITY_MAP: Record<string, { label: string; color: string }> = {
LOW: { label: 'Baja', color: 'gray' },
MEDIUM: { label: 'Media', color: 'blue' },
HIGH: { label: 'Alta', color: 'orange' },
URGENT: { label: 'Urgente', color: 'red' },
};
function statusBadge(s: string) { const c = STATUS_MAP[s]?.color ?? 'gray'; return `bg-${c}-100 text-${c}-800`; }
function statusLabel(s: string) { return STATUS_MAP[s]?.label ?? s.replace(/_/g, ' '); }
function priorityBadge(p: string) { const c = PRIORITY_MAP[p]?.color ?? 'gray'; return `bg-${c}-100 text-${c}-800`; }
function priorityLabel(p: string) { return PRIORITY_MAP[p]?.label ?? p; }
function fmtHours(h: number | null): string {
if (h == null) return '—';
if (h < 1) return `${Math.round(h * 60)} min`;
if (h < 24) return `${h.toFixed(1)} h`;
return `${(h / 24).toFixed(1)} días`;
}
function fmtDate(d: string): string {
return new Date(d).toLocaleDateString('es-MX', { day: '2-digit', month: 'short' });
}
function stars(r: number | null): string {
if (!r) return '—';
const n = Math.round(r);
return '★'.repeat(n) + '☆'.repeat(5 - n);
}
function changePct(val: number | null, type: 'tickets' | 'resolution'): { cls: string; txt: string } {
if (val == null) return { cls: '', txt: '' };
const arrow = val > 0 ? '↑' : '↓';
const cls = type === 'tickets'
? (val > 0 ? 'text-red-600' : 'text-green-600')
: (val > 0 ? 'text-green-600' : 'text-red-600');
return { cls, txt: `${arrow} ${Math.abs(val)}%` };
}
function slaBarColor(pct: number): string {
if (pct >= 90) return 'bg-green-500';
if (pct >= 70) return 'bg-yellow-400';
return 'bg-red-500';
}
function maxTrend(pts: any[]): number {
if (!pts?.length) return 1;
return Math.max(...pts.map((p: any) => Math.max(p.created, p.resolved, 1)));
}
</script>
<!-- PAGINA -->
<div class="p-6 space-y-6">
<!-- ENCABEZADO -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900">Reportes</h1>
<p class="text-sm text-gray-500 mt-1">Estadísticas y métricas del sistema de soporte</p>
</div>
<div class="flex items-center gap-3">
<label for="period-select" class="text-sm font-medium text-gray-600">Período:</label>
<select
id="period-select"
class="border rounded-lg px-3 py-2 text-sm bg-white shadow-sm focus:ring-2 focus:ring-blue-500"
bind:value={days}
on:change={reloadAll}
>
<option value={7}>Últimos 7 días</option>
<option value={30}>Últimos 30 días</option>
<option value={60}>Últimos 60 días</option>
<option value={90}>Últimos 90 días</option>
<option value={180}>Últimos 6 meses</option>
<option value={365}>Último año</option>
</select>
<button
class="px-3 py-2 bg-blue-700 text-white text-sm rounded-lg hover:bg-blue-800 transition disabled:opacity-50"
on:click={reloadAll}
disabled={isLoading}
>
{isLoading ? '...' : '↺ Actualizar'}
</button>
</div>
</div>
<!-- TABS -->
<div class="border-b border-gray-200">
<nav class="flex gap-1 overflow-x-auto">
{#each tabs as tab}
<button
class="flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 whitespace-nowrap transition
{activeTab === tab.id ? 'border-blue-700 text-blue-700' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}"
on:click={() => switchTab(tab.id)}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={tab.icon} />
</svg>
{tab.label}
</button>
{/each}
</nav>
</div>
<!-- SPINNER -->
{#if isLoading}
<div class="flex justify-center items-center py-16 text-gray-400 text-sm gap-2">
<svg class="animate-spin h-5 w-5 text-blue-700" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/>
</svg>
Cargando reporte...
</div>
<!-- RESUMEN -->
{:else if activeTab === 'summary' && summary}
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-white rounded-xl p-5 shadow-sm border border-gray-200">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Total tickets</p>
<p class="text-3xl font-bold text-gray-900 mt-2">{summary.total_tickets}</p>
{#if summary.tickets_change_pct != null}
{@const cp = changePct(summary.tickets_change_pct, 'tickets')}
<p class="text-sm mt-1 {cp.cls}">{cp.txt} vs período anterior</p>
{/if}
</div>
<div class="bg-white rounded-xl p-5 shadow-sm border-l-4 border-orange-400 border border-gray-100">
<p class="text-xs font-medium text-orange-500 uppercase tracking-wide">Abiertos</p>
<p class="text-3xl font-bold text-orange-500 mt-2">{summary.open_tickets}</p>
<p class="text-sm text-gray-400 mt-1">
{summary.total_tickets > 0 ? Math.round(summary.open_tickets / summary.total_tickets * 100) : 0}% del total
</p>
</div>
<div class="bg-white rounded-xl p-5 shadow-sm border-l-4 border-green-500 border border-gray-100">
<p class="text-xs font-medium text-green-600 uppercase tracking-wide">Resueltos</p>
<p class="text-3xl font-bold text-green-600 mt-2">{summary.resolved_tickets}</p>
{#if summary.resolution_change_pct != null}
{@const cp = changePct(summary.resolution_change_pct, 'resolution')}
<p class="text-sm mt-1 {cp.cls}">{cp.txt} tasa vs anterior</p>
{/if}
</div>
<div class="bg-white rounded-xl p-5 shadow-sm border-l-4 border-red-500 border border-gray-100">
<p class="text-xs font-medium text-red-500 uppercase tracking-wide">Urgentes</p>
<p class="text-3xl font-bold text-red-600 mt-2">{summary.by_priority.urgent}</p>
<p class="text-sm text-gray-400 mt-1">Tiempo prom: {fmtHours(summary.avg_resolution_hours)}</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-4 gap-4">
<div class="bg-white rounded-xl p-5 shadow-sm border border-gray-200">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Tiempo prom. resolución</p>
<p class="text-3xl font-bold text-blue-700 mt-2">{fmtHours(summary.avg_resolution_hours)}</p>
<p class="text-sm text-gray-400 mt-1">Primera resp: {fmtHours(summary.avg_first_response_hours)}</p>
</div>
<div class="bg-white rounded-xl p-5 shadow-sm border border-gray-200">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">Satisfacción (CSAT)</p>
{#if summary.avg_rating}
<p class="text-4xl font-bold text-yellow-500">{summary.avg_rating.toFixed(1)} <span class="text-2xl"></span></p>
<p class="text-sm text-gray-400 mt-1">{summary.total_rated} calificaciones</p>
{:else}
<p class="text-gray-400 text-sm mt-2">Sin calificaciones</p>
{/if}
</div>
<div class="bg-white rounded-xl p-5 shadow-sm border border-gray-200">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">Por estado</p>
<div class="space-y-2">
{#each Object.entries(summary.by_status).filter(([k]) => k !== 'total') as [s, count]}
{#if count > 0}
<div class="flex items-center justify-between">
<span class="text-xs px-2 py-0.5 rounded-full font-medium {statusBadge(s.toUpperCase())}">
{statusLabel(s.toUpperCase())}
</span>
<span class="text-sm font-semibold text-gray-700">{count}</span>
</div>
{/if}
{/each}
</div>
</div>
<div class="bg-white rounded-xl p-5 shadow-sm border border-gray-200">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">Por prioridad</p>
<div class="space-y-2">
{#each [['URGENT', summary.by_priority.urgent], ['HIGH', summary.by_priority.high], ['MEDIUM', summary.by_priority.medium], ['LOW', summary.by_priority.low]] as [p, cnt]}
{#if cnt > 0}
<div class="flex items-center gap-2">
<span class="text-xs px-2 py-0.5 rounded-full font-medium {priorityBadge(String(p))} w-20 text-center">
{priorityLabel(String(p))}
</span>
<div class="flex-1 bg-gray-100 rounded-full h-2">
<div class="h-2 rounded-full bg-blue-600"
style="width:{summary.by_priority.total > 0 ? Math.round(Number(cnt) / summary.by_priority.total * 100) : 0}%">
</div>
</div>
<span class="text-sm font-semibold w-6 text-right">{cnt}</span>
</div>
{/if}
{/each}
</div>
</div>
</div>
<!-- POR AGENTE -->
{:else if activeTab === 'agents' && agentReport}
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
<div class="px-6 py-4 border-b bg-gray-50">
<h2 class="font-semibold text-gray-700">Rendimiento por agente — {agentReport.total_agents} agentes</h2>
</div>
{#if agentReport.agents.length === 0}
<p class="p-8 text-center text-gray-400">No hay datos de agentes en este período.</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left font-medium text-gray-600">Agente</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Asignados</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Resueltos</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Abiertos</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Resolución %</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Tiempo prom.</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">CSAT</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Urgentes</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{#each agentReport.agents as a}
<tr class="hover:bg-gray-50 transition">
<td class="px-4 py-3">
<div class="font-medium text-gray-900">{a.agent_name}</div>
<div class="text-xs text-gray-400">{a.agent_email}</div>
</td>
<td class="px-4 py-3 text-center font-semibold">{a.total_assigned}</td>
<td class="px-4 py-3 text-center text-green-600 font-semibold">{a.resolved}</td>
<td class="px-4 py-3 text-center text-orange-500 font-semibold">{a.open}</td>
<td class="px-4 py-3 text-center">
<div class="flex items-center gap-2 justify-center">
<div class="w-16 bg-gray-200 rounded-full h-2">
<div class="h-2 rounded-full {a.resolution_rate >= 80 ? 'bg-green-500' : a.resolution_rate >= 50 ? 'bg-yellow-400' : 'bg-red-500'}"
style="width:{a.resolution_rate}%"></div>
</div>
<span class="text-xs font-medium">{a.resolution_rate}%</span>
</div>
</td>
<td class="px-4 py-3 text-center text-gray-600">{fmtHours(a.avg_resolution_hours)}</td>
<td class="px-4 py-3 text-center">
{#if a.avg_rating}
<span class="text-yellow-500 font-semibold">{a.avg_rating.toFixed(1)}</span>
<div class="text-xs text-gray-400">{a.total_rated} cal.</div>
{:else}
<span class="text-gray-300"></span>
{/if}
</td>
<td class="px-4 py-3 text-center">
{#if a.urgent_handled > 0}
<span class="text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-800 font-semibold">{a.urgent_handled}</span>
{:else}
<span class="text-gray-300"></span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<!-- POR CATEGORIA -->
{:else if activeTab === 'categories' && catReport}
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
<div class="px-6 py-4 border-b bg-gray-50 flex justify-between items-center">
<h2 class="font-semibold text-gray-700">Tickets por categoría</h2>
{#if catReport.uncategorized_count > 0}
<span class="text-xs bg-gray-100 text-gray-500 px-2 py-1 rounded-full">
+ {catReport.uncategorized_count} sin categoría
</span>
{/if}
</div>
{#if catReport.categories.length === 0}
<p class="p-8 text-center text-gray-400">No hay datos de categorías en este período.</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left font-medium text-gray-600">Categoría</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Total</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Abiertos</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Resueltos</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Tiempo prom.</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">SLA resp/resol</th>
<th class="px-4 py-3 text-left font-medium text-gray-600">Cumplimiento SLA</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{#each catReport.categories as cat}
<tr class="hover:bg-gray-50 transition">
<td class="px-4 py-3 font-medium text-gray-900">{cat.category_name}</td>
<td class="px-4 py-3 text-center font-semibold">{cat.total_tickets}</td>
<td class="px-4 py-3 text-center text-orange-500">{cat.open_tickets}</td>
<td class="px-4 py-3 text-center text-green-600">{cat.resolved_tickets}</td>
<td class="px-4 py-3 text-center text-gray-600">{fmtHours(cat.avg_resolution_hours)}</td>
<td class="px-4 py-3 text-center text-gray-500 text-xs">{cat.sla_response_hours}h / {cat.sla_resolution_hours}h</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<div class="flex-1 bg-gray-200 rounded-full h-2.5">
<div class="h-2.5 rounded-full {slaBarColor(cat.sla_compliance_pct)}" style="width:{cat.sla_compliance_pct}%"></div>
</div>
<span class="text-xs font-medium w-10 text-right">{cat.sla_compliance_pct}%</span>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<!-- POR SISTEMA -->
{:else if activeTab === 'systems' && sysReport}
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
<div class="px-6 py-4 border-b bg-gray-50 flex justify-between items-center">
<h2 class="font-semibold text-gray-700">Tickets por sistema afectado</h2>
{#if sysReport.no_system_count > 0}
<span class="text-xs bg-gray-100 text-gray-500 px-2 py-1 rounded-full">
+ {sysReport.no_system_count} sin sistema
</span>
{/if}
</div>
{#if sysReport.systems.length === 0}
<p class="p-8 text-center text-gray-400">No hay tickets con sistema asignado en este período.</p>
{:else}
{@const maxSys = Math.max(...sysReport.systems.map(s => s.total_tickets), 1)}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left font-medium text-gray-600">Sistema</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Total</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Abiertos</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Resueltos</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Urgentes</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Tiempo prom.</th>
<th class="px-4 py-3 text-left font-medium text-gray-600">Carga de trabajo</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{#each sysReport.systems as sys}
<tr class="hover:bg-gray-50 transition">
<td class="px-4 py-3 font-medium text-gray-900">{sys.system_name}</td>
<td class="px-4 py-3 text-center font-semibold">{sys.total_tickets}</td>
<td class="px-4 py-3 text-center text-orange-500">{sys.open_tickets}</td>
<td class="px-4 py-3 text-center text-green-600">{sys.resolved_tickets}</td>
<td class="px-4 py-3 text-center">
{#if sys.urgent_tickets > 0}
<span class="px-2 py-0.5 rounded-full bg-red-100 text-red-800 text-xs font-semibold">{sys.urgent_tickets}</span>
{:else}
<span class="text-gray-300"></span>
{/if}
</td>
<td class="px-4 py-3 text-center text-gray-600">{fmtHours(sys.avg_resolution_hours)}</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<div class="flex-1 bg-gray-100 rounded-full h-2.5">
<div class="h-2.5 rounded-full bg-blue-600" style="width:{Math.round(sys.total_tickets / maxSys * 100)}%"></div>
</div>
<span class="text-xs text-gray-400 w-8 text-right">{Math.round(sys.total_tickets / maxSys * 100)}%</span>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<!-- POR CLIENTE (solo ADMIN) -->
{:else if activeTab === 'clients' && isAdmin && clientReport}
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
<div class="px-6 py-4 border-b bg-gray-50">
<h2 class="font-semibold text-gray-700">Tickets por cliente — {clientReport.total_clients} clientes activos</h2>
</div>
{#if clientReport.clients.length === 0}
<p class="p-8 text-center text-gray-400">No hay datos de clientes en este período.</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left font-medium text-gray-600">Cliente</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Total</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Abiertos</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Resueltos</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Urgentes</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Tiempo prom.</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">CSAT</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">Último ticket</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{#each clientReport.clients as c}
<tr class="hover:bg-gray-50 transition">
<td class="px-4 py-3 font-medium text-gray-900">{c.tenant_name}</td>
<td class="px-4 py-3 text-center font-semibold">{c.total_tickets}</td>
<td class="px-4 py-3 text-center text-orange-500">{c.open_tickets}</td>
<td class="px-4 py-3 text-center text-green-600">{c.resolved_tickets}</td>
<td class="px-4 py-3 text-center">
{#if c.urgent_tickets > 0}
<span class="px-2 py-0.5 rounded-full bg-red-100 text-red-800 text-xs font-semibold">{c.urgent_tickets}</span>
{:else}
<span class="text-gray-300"></span>
{/if}
</td>
<td class="px-4 py-3 text-center text-gray-600">{fmtHours(c.avg_resolution_hours)}</td>
<td class="px-4 py-3 text-center text-yellow-500">
{c.avg_rating ? c.avg_rating.toFixed(1) + ' ★' : '—'}
</td>
<td class="px-4 py-3 text-center text-gray-400 text-xs">
{c.last_ticket_at ? new Date(c.last_ticket_at).toLocaleDateString('es-MX') : '—'}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<!-- TENDENCIAS -->
{:else if activeTab === 'trends' && trendsReport}
<div class="bg-white rounded-xl shadow-sm border p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="font-semibold text-gray-700">Tickets diarios — últimos {trendsReport.total_days} días</h2>
<div class="flex gap-4 text-xs text-gray-500">
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded bg-blue-400 inline-block"></span> Creados</span>
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded bg-green-500 inline-block"></span> Resueltos</span>
</div>
</div>
{#if trendsReport.data_points.length > 0}
{@const maxVal = maxTrend(trendsReport.data_points)}
<div class="overflow-x-auto">
<div class="relative" style="height:160px; min-width:max-content">
<div class="flex items-end gap-1 h-full border-b border-gray-200">
{#each trendsReport.data_points as pt}
<div class="w-3 bg-blue-400 rounded-t opacity-80 shrink-0"
style="height:{maxVal > 0 ? Math.round(pt.created / maxVal * 100) : 0}%"
title="Creados {pt.date}: {pt.created}"></div>
{/each}
</div>
<div class="absolute bottom-0 left-0 flex items-end gap-1 h-full pointer-events-none">
{#each trendsReport.data_points as pt}
<div class="w-3 bg-green-500 rounded-t opacity-60 shrink-0"
style="height:{maxVal > 0 ? Math.round(pt.resolved / maxVal * 100) : 0}%"
title="Resueltos {pt.date}: {pt.resolved}"></div>
{/each}
</div>
</div>
<div class="flex gap-1 mt-2" style="min-width:max-content">
{#each trendsReport.data_points as pt, i}
<div class="w-3 shrink-0 text-center">
{#if i % 7 === 0}
<span class="text-gray-400 block" style="font-size:0.55rem;writing-mode:vertical-rl">{fmtDate(pt.date)}</span>
{/if}
</div>
{/each}
</div>
</div>
<div class="mt-6 max-h-48 overflow-y-auto rounded border border-gray-100">
<table class="w-full text-xs">
<thead class="sticky top-0 bg-gray-50">
<tr class="border-b">
<th class="px-3 py-2 text-left text-gray-500 font-medium">Fecha</th>
<th class="px-3 py-2 text-center text-blue-500 font-medium">Creados</th>
<th class="px-3 py-2 text-center text-green-600 font-medium">Resueltos</th>
<th class="px-3 py-2 text-center text-gray-500 font-medium">Balance</th>
</tr>
</thead>
<tbody>
{#each [...trendsReport.data_points].reverse() as pt}
{#if pt.created > 0 || pt.resolved > 0}
<tr class="border-b hover:bg-gray-50">
<td class="px-3 py-1.5 text-gray-600">{pt.date}</td>
<td class="px-3 py-1.5 text-center text-blue-600 font-semibold">{pt.created}</td>
<td class="px-3 py-1.5 text-center text-green-600 font-semibold">{pt.resolved}</td>
<td class="px-3 py-1.5 text-center font-semibold {pt.net_open > 0 ? 'text-red-500' : pt.net_open < 0 ? 'text-green-500' : 'text-gray-400'}">
{pt.net_open > 0 ? '+' : ''}{pt.net_open}
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{:else}
<p class="text-center text-gray-400 py-8">No hay datos en este período.</p>
{/if}
</div>
<!-- CSAT -->
{:else if activeTab === 'csat' && csatReport}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="font-semibold text-gray-700 mb-4">Resumen CSAT</h3>
{#if csatReport.avg_rating}
<div class="text-center">
<p class="text-5xl font-bold text-yellow-500">{csatReport.avg_rating.toFixed(1)}</p>
<p class="text-3xl mt-1 text-yellow-400">{stars(csatReport.avg_rating)}</p>
<p class="text-sm text-gray-500 mt-2">{csatReport.total_rated} de {csatReport.total_tickets} tickets calificados</p>
<p class="text-sm font-medium text-blue-600 mt-1">{csatReport.response_rate}% tasa de respuesta</p>
</div>
<div class="mt-6 space-y-2">
{#each [5, 4, 3, 2, 1] as star}
{@const count = csatReport.distribution[`rating_${star}`] ?? 0}
{@const pct = csatReport.total_rated > 0 ? Math.round(count / csatReport.total_rated * 100) : 0}
<div class="flex items-center gap-2 text-sm">
<span class="text-yellow-400 w-6 text-right shrink-0">{star}</span>
<div class="flex-1 bg-gray-100 rounded-full h-3">
<div class="h-3 rounded-full bg-yellow-400" style="width:{pct}%"></div>
</div>
<span class="text-gray-500 w-8 text-right text-xs shrink-0">{count}</span>
</div>
{/each}
</div>
{:else}
<p class="text-gray-400 text-center py-4">Sin calificaciones en este período</p>
{/if}
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="font-semibold text-gray-700 mb-4">CSAT por categoría</h3>
{#if csatReport.by_category.length}
<div class="space-y-3">
{#each csatReport.by_category as item}
<div>
<div class="flex justify-between items-center text-sm mb-1">
<span class="text-gray-700 truncate">{item.category}</span>
<span class="text-yellow-500 font-medium ml-2 shrink-0">{item.avg_rating ? item.avg_rating.toFixed(1) + ' ★' : '—'}</span>
</div>
<div class="bg-gray-100 rounded-full h-2">
<div class="h-2 rounded-full bg-yellow-400" style="width:{item.avg_rating ? item.avg_rating / 5 * 100 : 0}%"></div>
</div>
<p class="text-xs text-gray-400 mt-0.5">{item.total_rated} calificaciones</p>
</div>
{/each}
</div>
{:else}
<p class="text-gray-400 text-sm">Sin datos</p>
{/if}
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="font-semibold text-gray-700 mb-4">CSAT por agente</h3>
{#if csatReport.by_agent.length}
<div class="space-y-3">
{#each csatReport.by_agent as item}
<div>
<div class="flex justify-between items-center text-sm mb-1">
<span class="text-gray-700 truncate">{item.agent}</span>
<span class="text-yellow-500 font-medium ml-2 shrink-0">{item.avg_rating ? item.avg_rating.toFixed(1) + ' ★' : '—'}</span>
</div>
<div class="bg-gray-100 rounded-full h-2">
<div class="h-2 rounded-full bg-yellow-400" style="width:{item.avg_rating ? item.avg_rating / 5 * 100 : 0}%"></div>
</div>
<p class="text-xs text-gray-400 mt-0.5">{item.total_rated} calificaciones</p>
</div>
{/each}
</div>
{:else}
<p class="text-gray-400 text-sm">Sin datos</p>
{/if}
</div>
</div>
{#if csatReport.recent_comments?.length > 0}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="font-semibold text-gray-700 mb-4">Comentarios recientes</h3>
<div class="space-y-3">
{#each csatReport.recent_comments as c}
<div class="flex gap-3 items-start pb-3 border-b border-gray-100 last:border-0">
<span class="text-yellow-400 font-bold text-lg shrink-0 leading-none">
{'★'.repeat(c.rating)}{'☆'.repeat(5 - c.rating)}
</span>
<div>
<p class="text-sm text-gray-700">{c.comment}</p>
<p class="text-xs text-gray-400 mt-0.5">
{c.rated_at ? new Date(c.rated_at).toLocaleDateString('es-MX', { day: '2-digit', month: 'short', year: 'numeric' }) : ''}
</p>
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- ESTADO VACIO -->
{:else if !isLoading}
<div class="flex justify-center py-16">
<p class="text-gray-400">Selecciona un período o cambia de pestaña para ver el reporte.</p>
</div>
{/if}
</div>

View File

@@ -0,0 +1,492 @@
<script lang="ts">
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import { goto } from '$app/navigation';
onMount(() => {
if (!$auth.isAuthenticated) goto('/login');
});
// ─── Types ──────────────────────────────────────────────────────────────────
interface EndpointDef {
id: string;
label: string;
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
path: string;
description: string;
}
interface EndpointResult {
status: number | null;
ok: boolean | null;
ms: number | null;
error: string | null;
preview: string | null;
tested: boolean;
loading: boolean;
}
interface PageDef {
label: string;
href: string;
description: string;
roles: string[];
}
// ─── Backend Endpoints ───────────────────────────────────────────────────────
const GROUPS: { name: string; color: string; endpoints: EndpointDef[] }[] = [
{
name: 'Auth',
color: 'bg-purple-100 text-purple-800',
endpoints: [
{ id: 'auth-me', label: 'Perfil actual', method: 'GET', path: '/auth/me', description: 'Información del usuario autenticado' },
{ id: 'auth-refresh', label: 'Refrescar token', method: 'POST', path: '/auth/refresh', description: 'Renovar access token (POST)' },
]
},
{
name: 'Health',
color: 'bg-green-100 text-green-800',
endpoints: [
{ id: 'health', label: 'Health Check', method: 'GET', path: '/health', description: 'Estado general del sistema' },
{ id: 'health-details', label: 'Health Detallado', method: 'GET', path: '/health/detailed', description: 'Estado con detalle de dependencias' },
]
},
{
name: 'Tenants',
color: 'bg-blue-100 text-blue-800',
endpoints: [
{ id: 'tenants-list', label: 'Listar Tenants', method: 'GET', path: '/tenants/', description: 'Todos los tenants registrados' },
{ id: 'tenant-stats', label: 'Stats Tenant', method: 'GET', path: '/tenants/stats', description: 'Estadísticas globales de tenants' },
]
},
{
name: 'Users',
color: 'bg-indigo-100 text-indigo-800',
endpoints: [
{ id: 'users-list', label: 'Listar Usuarios', method: 'GET', path: '/users/', description: 'Todos los usuarios del sistema' },
]
},
{
name: 'Tickets',
color: 'bg-orange-100 text-orange-800',
endpoints: [
{ id: 'tickets-list', label: 'Listar Tickets', method: 'GET', path: '/tickets/', description: 'Tickets con paginación' },
{ id: 'tickets-stats', label: 'Stats Tickets', method: 'GET', path: '/tickets/stats', description: 'Estadísticas de tickets' },
{ id: 'tickets-comments',label: 'Comentarios recientes', method: 'GET', path: '/tickets/comments/recent',description: 'Últimos comentarios' },
]
},
{
name: 'Categories',
color: 'bg-amber-100 text-amber-800',
endpoints: [
{ id: 'cats-list', label: 'Listar Categorías', method: 'GET', path: '/categories/', description: 'Categorías de tickets' },
]
},
{
name: 'Systems',
color: 'bg-gray-100 text-gray-800',
endpoints: [
{ id: 'sys-list', label: 'Listar Sistemas', method: 'GET', path: '/systems/', description: 'Sistemas soportados' },
]
},
{
name: 'SLA',
color: 'bg-teal-100 text-teal-800',
endpoints: [
{ id: 'sla-dashboard', label: 'Dashboard SLA', method: 'GET', path: '/sla/dashboard', description: 'Panel SLA principal' },
{ id: 'sla-compliance', label: 'SLA Compliance', method: 'GET', path: '/sla/compliance', description: 'Métricas de cumplimiento SLA' },
{ id: 'sla-at-risk', label: 'Tickets en Riesgo', method: 'GET', path: '/sla/at-risk', description: 'Tickets próximos a violar SLA' },
{ id: 'sla-violations', label: 'Violaciones SLA', method: 'GET', path: '/sla/violations', description: 'Tickets que violaron SLA' },
]
},
{
name: 'Reports',
color: 'bg-pink-100 text-pink-800',
endpoints: [
{ id: 'rep-summary', label: 'Resumen General', method: 'GET', path: '/reports/summary', description: 'Resumen ejecutivo de reportes' },
{ id: 'rep-agents', label: 'Por Agente', method: 'GET', path: '/reports/agents', description: 'Rendimiento por agente' },
{ id: 'rep-categories', label: 'Por Categoría', method: 'GET', path: '/reports/categories', description: 'Distribución por categoría' },
{ id: 'rep-trends', label: 'Tendencias', method: 'GET', path: '/reports/trends', description: 'Tendencias temporales' },
]
},
{
name: 'Audit',
color: 'bg-red-100 text-red-800',
endpoints: [
{ id: 'audit-logs', label: 'Logs de Auditoría', method: 'GET', path: '/audit/logs', description: 'Bitácora de acciones' },
{ id: 'audit-stats', label: 'Stats Auditoría', method: 'GET', path: '/audit/stats', description: 'Estadísticas de auditoría' },
{ id: 'audit-security', label: 'Análisis Seguridad', method: 'GET', path: '/audit/security/analysis',description: 'Análisis de amenazas de seguridad' },
{ id: 'audit-users', label: 'Actividad Usuarios', method: 'GET', path: '/audit/users', description: 'Actividad por usuario' },
]
},
{
name: 'Client Profile',
color: 'bg-cyan-100 text-cyan-800',
endpoints: [
{ id: 'client-profile', label: 'Perfil Cliente', method: 'GET', path: '/client/profile', description: 'Perfil organización cliente' },
{ id: 'client-tickets', label: 'Tickets Cliente', method: 'GET', path: '/client/tickets', description: 'Tickets del cliente' },
]
},
];
// ─── Frontend Pages ──────────────────────────────────────────────────────────
const FRONTEND_PAGES: PageDef[] = [
{ label: 'Dashboard', href: '/', description: 'Panel principal de administración', roles: ['todos'] },
{ label: 'Tickets', href: '/tickets', description: 'Gestión y listado de tickets', roles: ['ADMIN', 'SUPPORT_MANAGER', 'AGENT'] },
{ label: 'Clientes (Tenants)', href: '/tenants', description: 'Administración de organizaciones cliente', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
{ label: 'Usuarios', href: '/users', description: 'Gestión de usuarios internos', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
{ label: 'Categorías', href: '/categories', description: 'Categorías y SLA por área', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
{ label: 'Sistemas', href: '/systems', description: 'Catálogo de sistemas soportados', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
{ label: 'SLA Dashboard', href: '/sla', description: 'Monitoreo de SLAs y cumplimiento', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
{ label: 'SLA En Riesgo', href: '/sla/at-risk', description: 'Tickets próximos a violar SLA', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
{ label: 'SLA Violaciones', href: '/sla/violations', description: 'Historial de violaciones SLA', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
{ label: 'Reportes', href: '/reports', description: 'Reportes estadísticos e informes', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
{ label: 'Auditoría', href: '/audit', description: 'Bitácora de acciones del sistema', roles: ['ADMIN', 'AUDITOR'] },
{ label: 'Seguridad', href: '/audit/security', description: 'Análisis de amenazas y eventos de seguridad',roles: ['ADMIN'] },
{ label: 'Perfil', href: '/profile', description: 'Perfil y configuración de seguridad', roles: ['todos'] },
{ label: 'Rate Limits', href: '/rate-limits', description: 'Estado de rate limiting por IP', roles: ['ADMIN'] },
{ label: 'Reporte Endpoints', href: '/test-report', description: 'Esta misma página', roles: ['ADMIN'] },
];
// ─── State ───────────────────────────────────────────────────────────────────
let results: Record<string, EndpointResult> = {};
let isTesting = false;
let testingId: string | null = null;
let totalOk = 0;
let totalFail = 0;
let activeTab: 'endpoints' | 'pages' = 'endpoints';
// Init results
for (const group of GROUPS) {
for (const ep of group.endpoints) {
results[ep.id] = { status: null, ok: null, ms: null, error: null, preview: null, tested: false, loading: false };
}
}
function getToken(): string | null {
return (typeof window !== 'undefined')
? localStorage.getItem('internal_auth_token')
: null;
}
function getTenantId(): string | null {
if (typeof window === 'undefined') return null;
try {
const stored = localStorage.getItem('internal_auth_user');
if (stored) return JSON.parse(stored)?.tenant_id ?? null;
} catch { /* ignore */ }
return null;
}
async function testEndpoint(ep: EndpointDef) {
results[ep.id] = { ...results[ep.id], loading: true, tested: false };
results = results; // trigger reactivity
const token = getToken();
const tenantId = getTenantId();
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
if (tenantId) headers['X-Tenant-ID'] = tenantId;
const url = `/api/v1${ep.path}`;
const t0 = performance.now();
try {
let fetchOptions: RequestInit = { method: ep.method, headers };
// For non-GET we don't send a body to avoid validation errors
const res = await fetch(url, fetchOptions);
const ms = Math.round(performance.now() - t0);
let preview: string | null = null;
try {
const text = await res.text();
const obj = JSON.parse(text);
preview = JSON.stringify(obj, null, 2).slice(0, 500);
if (JSON.stringify(obj, null, 2).length > 500) preview += '\n...';
} catch { /* ignore */ }
results[ep.id] = { status: res.status, ok: res.ok, ms, error: null, preview, tested: true, loading: false };
} catch (e: any) {
const ms = Math.round(performance.now() - t0);
results[ep.id] = { status: null, ok: false, ms, error: e.message ?? 'Network error', preview: null, tested: true, loading: false };
}
results = results;
recalcCounters();
}
async function testAll() {
isTesting = true;
totalOk = 0;
totalFail = 0;
for (const group of GROUPS) {
for (const ep of group.endpoints) {
testingId = ep.id;
await testEndpoint(ep);
}
}
testingId = null;
isTesting = false;
}
function recalcCounters() {
totalOk = Object.values(results).filter(r => r.tested && r.ok).length;
totalFail = Object.values(results).filter(r => r.tested && !r.ok).length;
}
function statusBadge(r: EndpointResult): { text: string; cls: string } {
if (r.loading) return { text: 'Probando...', cls: 'bg-gray-100 text-gray-600 animate-pulse' };
if (!r.tested) return { text: 'Sin probar', cls: 'bg-gray-100 text-gray-400' };
if (r.ok) return { text: `${r.status} OK`, cls: 'bg-green-100 text-green-700' };
return { text: r.status ? `${r.status} Error` : 'Fallo red', cls: 'bg-red-100 text-red-700' };
}
function methodBadge(method: string): string {
const map: Record<string, string> = {
GET: 'bg-blue-100 text-blue-700',
POST: 'bg-green-100 text-green-700',
PUT: 'bg-yellow-100 text-yellow-700',
DELETE: 'bg-red-100 text-red-700',
PATCH: 'bg-purple-100 text-purple-700',
};
return map[method] ?? 'bg-gray-100 text-gray-700';
}
let expandedIds = new Set<string>();
function toggleExpand(id: string) {
if (expandedIds.has(id)) expandedIds.delete(id);
else expandedIds.add(id);
expandedIds = new Set(expandedIds);
}
const testedCount = () => Object.values(results).filter(r => r.tested).length;
const totalEndpoints = GROUPS.reduce((acc, g) => acc + g.endpoints.length, 0);
</script>
<svelte:head>
<title>Reporte de Endpoints - ServiceManager</title>
</svelte:head>
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
<!-- Header -->
<div class="md:flex md:items-center md:justify-between mb-6">
<div>
<h2 class="text-2xl font-bold text-gray-900">Reporte de Endpoints & Páginas</h2>
<p class="mt-1 text-sm text-gray-500">
Diagnóstico de conectividad de todos los endpoints del backend y páginas del frontend.
</p>
</div>
<div class="mt-4 flex gap-2 md:mt-0">
<button
on:click={testAll}
disabled={isTesting}
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
>
{#if isTesting}
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Probando endpoints...
{:else}
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Probar Todo
{/if}
</button>
</div>
</div>
<!-- Summary Bar -->
{#if testedCount() > 0}
<div class="mb-6 grid grid-cols-3 gap-4">
<div class="bg-white rounded-lg border p-4 text-center">
<div class="text-2xl font-bold text-gray-800">{testedCount()}/{totalEndpoints}</div>
<div class="text-xs text-gray-500 mt-1">Endpoints probados</div>
</div>
<div class="bg-green-50 rounded-lg border border-green-200 p-4 text-center">
<div class="text-2xl font-bold text-green-700">{totalOk}</div>
<div class="text-xs text-green-600 mt-1">OK / Exitosos</div>
</div>
<div class="bg-red-50 rounded-lg border border-red-200 p-4 text-center">
<div class="text-2xl font-bold text-red-700">{totalFail}</div>
<div class="text-xs text-red-600 mt-1">Errores / Fallidos</div>
</div>
</div>
{/if}
<!-- Tabs -->
<div class="flex border-b border-gray-200 mb-6">
<button
on:click={() => activeTab = 'endpoints'}
class="px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors {activeTab === 'endpoints' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}"
>
Endpoints Backend ({totalEndpoints})
</button>
<button
on:click={() => activeTab = 'pages'}
class="px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors {activeTab === 'pages' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}"
>
Páginas Frontend ({FRONTEND_PAGES.length})
</button>
</div>
<!-- ─── Endpoints Tab ─────────────────────────────────────────────────────── -->
{#if activeTab === 'endpoints'}
<div class="space-y-6">
{#each GROUPS as group}
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<!-- Group header -->
<div class="flex items-center justify-between px-5 py-3 bg-gray-50 border-b border-gray-200">
<div class="flex items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-wider px-2 py-0.5 rounded-full {group.color}">
{group.name}
</span>
<span class="text-xs text-gray-400">{group.endpoints.length} endpoint{group.endpoints.length !== 1 ? 's' : ''}</span>
</div>
<div class="flex gap-1 items-center">
{#each group.endpoints as ep}
{#if results[ep.id].tested}
<span class="w-2 h-2 rounded-full {results[ep.id].ok ? 'bg-green-400' : 'bg-red-400'}" title={ep.label}></span>
{/if}
{/each}
</div>
</div>
<!-- Endpoints list -->
<div class="divide-y divide-gray-100">
{#each group.endpoints as ep}
{@const r = results[ep.id]}
{@const badge = statusBadge(r)}
<div class="px-5 py-3">
<div class="flex items-center gap-3 flex-wrap">
<!-- Method badge -->
<span class="text-xs font-bold px-2 py-0.5 rounded font-mono {methodBadge(ep.method)}">
{ep.method}
</span>
<!-- Path -->
<code class="text-xs text-gray-700 bg-gray-50 px-2 py-0.5 rounded border border-gray-200 font-mono flex-shrink-0">
/api/v1{ep.path}
</code>
<!-- Label -->
<span class="text-sm text-gray-700 flex-1 min-w-0 truncate">{ep.label}</span>
<!-- Status + timing -->
<div class="flex items-center gap-2 ml-auto flex-shrink-0">
{#if r.ms !== null && r.tested}
<span class="text-xs text-gray-400">{r.ms}ms</span>
{/if}
<span class="text-xs font-medium px-2 py-0.5 rounded-full {badge.cls}">{badge.text}</span>
<!-- Test individual -->
<button
on:click={() => testEndpoint(ep)}
disabled={r.loading || isTesting}
class="ml-1 text-xs px-2 py-1 rounded border border-gray-200 hover:border-blue-300 hover:text-blue-600 text-gray-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{r.loading ? '...' : 'Probar'}
</button>
<!-- Expand preview -->
{#if r.tested && r.preview}
<button
on:click={() => toggleExpand(ep.id)}
class="text-xs px-2 py-1 rounded border border-gray-200 hover:border-blue-300 hover:text-blue-600 text-gray-500 transition-colors"
>
{expandedIds.has(ep.id) ? 'Ocultar' : 'Ver respuesta'}
</button>
{/if}
</div>
</div>
<!-- Description -->
<p class="mt-0.5 text-xs text-gray-400 ml-0.5">{ep.description}</p>
<!-- Error message -->
{#if r.tested && r.error}
<div class="mt-2 text-xs text-red-600 bg-red-50 rounded px-2 py-1.5 font-mono">{r.error}</div>
{/if}
<!-- Response preview -->
{#if expandedIds.has(ep.id) && r.preview}
<pre class="mt-2 text-xs text-gray-700 bg-gray-50 border border-gray-200 rounded p-3 overflow-x-auto whitespace-pre-wrap break-words max-h-48">{r.preview}</pre>
{/if}
</div>
{/each}
</div>
</div>
{/each}
</div>
{/if}
<!-- ─── Frontend Pages Tab ─────────────────────────────────────────────────── -->
{#if activeTab === 'pages'}
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Página</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ruta</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Descripción</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Roles</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Acción</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-100">
{#each FRONTEND_PAGES as pg}
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-6 py-3">
<span class="text-sm font-medium text-gray-900">{pg.label}</span>
</td>
<td class="px-6 py-3">
<code class="text-xs bg-gray-100 text-gray-700 px-2 py-0.5 rounded font-mono">{pg.href}</code>
</td>
<td class="px-6 py-3">
<span class="text-xs text-gray-500">{pg.description}</span>
</td>
<td class="px-6 py-3">
<div class="flex flex-wrap gap-1">
{#each pg.roles as role}
<span class="text-xs px-1.5 py-0.5 rounded-full bg-blue-50 text-blue-600 font-medium">{role}</span>
{/each}
</div>
</td>
<td class="px-6 py-3">
<a
href={pg.href}
target="_blank"
rel="noopener noreferrer"
class="text-xs text-blue-600 hover:text-blue-800 underline font-medium"
>
Abrir ↗
</a>
</td>
</tr>
{/each}
</tbody>
</table>
<!-- Notes -->
<div class="px-6 py-4 bg-gray-50 border-t border-gray-200">
<p class="text-xs text-gray-500">
<strong>Nota:</strong> Las páginas se abren en una nueva pestaña para verificar su renderizado.
Asegúrate de estar autenticado antes de acceder a rutas protegidas.
</p>
<div class="mt-3 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{#each FRONTEND_PAGES as pg}
<a
href={pg.href}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-200 hover:border-blue-300 hover:bg-blue-50 text-xs text-gray-700 hover:text-blue-700 transition-all group"
>
<svg class="w-3.5 h-3.5 text-gray-400 group-hover:text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
<span class="truncate font-medium">{pg.label}</span>
<code class="ml-auto text-gray-400 text-xs flex-shrink-0">{pg.href}</code>
</a>
{/each}
</div>
</div>
</div>
{/if}
</div>

View File

@@ -4,12 +4,23 @@ import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
port: 3000,
// Puerto: dentro del contenedor siempre 3000; Docker mapea 3001:3000 al host
port: parseInt(process.env.PORT || '3001'),
host: '0.0.0.0',
watch: {
usePolling: true,
interval: 500
},
// HMR: el browser llega al contenedor a través del puerto 3001 del host
hmr: {
host: 'localhost',
clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3001')
},
// Permitir que Vite sirva archivos del filesystem del contenedor
fs: {
allow: ['/app', '.'],
strict: false
},
proxy: {
'/api': {
target: process.env.PUBLIC_API_URL || 'http://localhost:8000',
@@ -19,10 +30,10 @@ export default defineConfig({
}
},
preview: {
port: 3000,
port: parseInt(process.env.PORT || '3001'),
host: '0.0.0.0'
},
build: {
target: 'esnext'
}
build: {
target: 'esnext'
}
});

View File

@@ -216,15 +216,18 @@ async def main():
if user_data["email"] in existing_emails:
print(f" ⏭ Ya existe: {user_data['email']}")
continue
pwd = user_data.pop("password")
# Usar copia para no mutar el dict original (permite re-ejecutar el script)
ud = user_data.copy()
pwd = ud.pop("password")
hashed_pwd = security.hash_password(pwd)
user = User(
tenant_id=tenant_id,
password_hash=hashed_pwd,
**user_data,
email_verified=True, # Marcar como verificado para permitir login
**ud,
)
session.add(user)
print(f"{user_data['email']} [{user_data['role'].value}] pwd={pwd}")
print(f"{ud['email']} [{ud['role'].value}] pwd={pwd}")
created_users += 1
await session.commit()